diff --git a/.github/workflows/platform-schema-conformance.yml b/.github/workflows/platform-schema-conformance.yml new file mode 100644 index 000000000..6f4a7f7d0 --- /dev/null +++ b/.github/workflows/platform-schema-conformance.yml @@ -0,0 +1,29 @@ +name: platform-schema conformance + +# Verify docs/platforms/schema/*.toml stays in sync with the code: structural +# schema validation + that every `source` code-ref still exists in the tree. +# Runs on any change to the schema files, the template, or the checker itself. +on: + pull_request: + paths: + - "docs/platforms/schema/**" + - "docs/platforms/_template.toml" + - "crates/platform-schema/**" + push: + branches: [main] + paths: + - "docs/platforms/schema/**" + - "docs/platforms/_template.toml" + - "crates/platform-schema/**" + +jobs: + conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + # Standalone crate (serde + toml only); excluded from the root workspace + # so it builds without the heavy adapter crates. + # --locked: build strictly from the committed Cargo.lock; fail if it is + # stale or missing rather than silently regenerating it (reproducible CI). + - run: cargo test --locked --manifest-path crates/platform-schema/Cargo.toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7695c737d..bd29f6024 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,6 +172,18 @@ gh run view --repo openabdev/openab --json conclusion -q .conclusion - Run `cargo clippy` and address warnings - Keep PRs focused — one feature or fix per PR +## Platform Schema + +When modifying a platform adapter (`crates/openab-gateway/src/adapters/*.rs`), check whether the change affects the platform's documented capabilities or feature status. If it does, update the corresponding `docs/platforms/schema/.toml`. + +See [`docs/platforms/README.md`](docs/platforms/README.md) for: +- The three-schema structure (capability, feature-support, quirks) +- How to add a new feature to the closed set +- How to add a new platform +- Architecture: TOML (machine facts) vs `docs/.md` (human setup guide) + +CI runs conformance tests on schema changes — missing features, invalid enums, or broken code-ref `source` fields will fail the build. + ## PR Lifecycle Every PR follows a label-driven lifecycle that keeps the review loop moving. diff --git a/Cargo.toml b/Cargo.toml index 69b6f01ae..a85c694a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = ["crates/openab-core", "crates/openab-gateway"] -exclude = ["openab-agent"] +exclude = ["openab-agent", "crates/platform-schema"] [package] name = "openab" diff --git a/crates/platform-schema/.gitignore b/crates/platform-schema/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/crates/platform-schema/.gitignore @@ -0,0 +1 @@ +/target diff --git a/crates/platform-schema/Cargo.lock b/crates/platform-schema/Cargo.lock new file mode 100644 index 000000000..d709abdd4 --- /dev/null +++ b/crates/platform-schema/Cargo.lock @@ -0,0 +1,163 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "platform-schema" +version = "0.1.0" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/crates/platform-schema/Cargo.toml b/crates/platform-schema/Cargo.toml new file mode 100644 index 000000000..8737f3d60 --- /dev/null +++ b/crates/platform-schema/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "platform-schema" +version = "0.1.0" +edition = "2021" +publish = false +description = "Authoritative types + conformance tests for docs/platforms/schema/*.toml" + +# Standalone crate (excluded from the root workspace) so it builds independently +# of the heavy adapter crates. The serde structs below ARE the schema; each +# schema/.toml deserializes into `Platform`. +# +# Run: cargo test (from this directory) + +[dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.8" diff --git a/crates/platform-schema/src/lib.rs b/crates/platform-schema/src/lib.rs new file mode 100644 index 000000000..3c8ec8d05 --- /dev/null +++ b/crates/platform-schema/src/lib.rs @@ -0,0 +1,469 @@ +//! Platform capability schema — authoritative types for `docs/platforms/schema/*.toml`. +//! +//! Each `schema/.toml` deserializes into [`Platform`]. The conformance +//! tests in `tests/conformance.rs` validate structure, enums, the closed feature +//! set, and that every code-ref `source` still exists in the tree. +//! +//! The blank template + human-readable field docs live in +//! `docs/platforms/_template.toml`; keep the two in sync (a conformance test +//! checks the template still enumerates every section + feature key). + +use serde::Deserialize; + +/// Current schema version. Bump when the schema changes; stale files are flagged. +pub const SCHEMA_VERSION: &str = "2026-07-08"; + +/// The complete, closed OpenAB feature set (Schema 2). Every platform file must +/// contain exactly these keys, once each. +pub const EXPECTED_FEATURES: &[&str] = &[ + "send_message", + "message_split", + "streaming", + "reply_quote", + "edit_message", + "delete_message", + "emoji_reactions", + "threads_topics", + "media_inbound", + "voice_stt", + "trust_gate", + "deny_echo", + "mention_gating", + "slash_commands", + "multibot", + "group_routing", + "cron_dispatch", +]; + +/// Every `[capability.*]` sub-section name, in template order. +pub const CAPABILITY_SECTIONS: &[&str] = &[ + "transport", + "inbound_auth", + "threads", + "slash_commands", + "mentions", + "emoji_reactions", + "edit_message", + "delete_message", + "rich_content", + "attachments", + "message_length_limit", + "dm_support", + "group_model", + "group_sender_identity", + "send_model", + "proactive_push", + "bot_to_bot", + "typing_indicator", +]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Platform { + pub schema_version: String, + pub platform: Identity, + pub capability: Capability, + #[serde(default)] + pub openab_features: Vec, + #[serde(default)] + pub quirks: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Identity { + pub name: String, + pub official_docs: String, + pub description: String, +} + +// ─── Schema 1 — platform-capability ───────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Capability { + pub transport: Transport, + pub inbound_auth: InboundAuth, + pub threads: Threads, + pub slash_commands: SlashCommands, + pub mentions: Mentions, + pub emoji_reactions: EmojiReactions, + pub edit_message: EditMessage, + pub delete_message: DeleteMessage, + pub rich_content: RichContent, + pub attachments: Attachments, + pub message_length_limit: MessageLengthLimit, + pub dm_support: DmSupport, + pub group_model: GroupModel, + pub group_sender_identity: GroupSenderIdentity, + pub send_model: SendModel, + pub proactive_push: ProactivePush, + pub bot_to_bot: BotToBot, + pub typing_indicator: TypingIndicator, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransportKind { + Webhook, + Websocket, + SocketMode, + LongPoll, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Transport { + pub kind: TransportKind, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthScheme { + HmacSha256, + JwtRs256, + Aes, + SharedSecret, + Oauth, + None, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InboundAuth { + pub scheme: AuthScheme, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadModel { + Native, + ReplyToOnly, + Emulated, + None, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Threads { + pub model: ThreadModel, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SlashCommands { + pub supported: bool, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MentionMethod { + AtMention, + Username, + SelfFlag, + None, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Mentions { + pub method: MentionMethod, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EmojiReactions { + pub bot_can_add: bool, + pub bot_can_remove: bool, + pub bot_receives_events: bool, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EditMessage { + pub supported: bool, + /// Cap on edits per message, if the platform imposes one (e.g. Feishu = 20). + #[serde(default)] + pub max_edits: Option, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeleteScope { + None, + Own, + Others, + OwnAndOthers, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeleteMessage { + pub supported: bool, + pub scope: DeleteScope, + /// Deletion time window in seconds, if bounded (e.g. WeCom recall = 86400). + #[serde(default)] + pub window_sec: Option, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RichContent { + pub markdown: bool, + pub cards: bool, + pub buttons: bool, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AttachmentKind { + Image, + Audio, + Video, + File, +} + +/// How the bot delivers outbound media: by URL reference, or by uploading bytes. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OutboundDelivery { + Url, + Upload, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Attachments { + pub inbound: Vec, + pub outbound: Vec, + #[serde(default)] + pub max_size_mb: Option, + /// Max attachments per message, if the platform caps it (e.g. Discord = 10). + #[serde(default)] + pub max_count: Option, + #[serde(default)] + pub outbound_delivery: Option, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MessageLengthLimit { + pub max_chars: u32, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DmSupport { + pub supported: bool, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GroupModel { + pub kinds: Vec, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StableId { + Yes, + No, + ConsentGated, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GroupSenderIdentity { + pub stable_id: StableId, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SendModelKind { + AnyTime, + ReplyOnly, + PushOnly, + Hybrid, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SendModel { + pub model: SendModelKind, + #[serde(default)] + pub reply_token_ttl_sec: Option, + #[serde(default)] + pub max_objects_per_send: Option, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuotaModel { + Unlimited, + Metered, + None, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProactivePush { + pub supported: bool, + pub quota_model: QuotaModel, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BotToBot { + pub delivered: bool, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TypingIndicator { + pub supported: bool, + pub note: String, + pub source: String, +} + +// ─── Schema 2 — openab-feature-support ────────────────────────────────────── + +#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum Status { + Implemented, + Partial, + Workaround, + NotImplemented, + #[serde(rename = "n_a")] + Na, +} + +impl Status { + /// A claimed-present feature must cite where it lives. + pub fn requires_source(self) -> bool { + matches!(self, Status::Implemented | Status::Partial | Status::Workaround) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Feature { + pub feature: String, + pub status: Status, + pub note: String, + #[serde(default)] + pub source: Vec, + #[serde(default)] + pub pr: Option, +} + +// ─── Schema 3 — platform-quirks ───────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuirkKind { + Intrinsic, + OpenabDecision, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Quirk { + pub date: String, + pub title: String, + pub note: String, + pub kind: QuirkKind, + #[serde(default)] + pub source: Option, + #[serde(default)] + pub refs: Vec, +} + +// ─── source-ref parsing ───────────────────────────────────────────────────── + +/// A parsed code-ref source: `"path/to/file.rs"` or `"path/to/file.rs#symbol"`. +#[derive(Debug)] +pub struct CodeRef<'a> { + pub file: &'a str, + pub symbol: Option<&'a str>, +} + +/// Split a source string into file + optional `#symbol`. +pub fn parse_code_ref(s: &str) -> CodeRef<'_> { + match s.split_once('#') { + Some((file, symbol)) => CodeRef { file, symbol: Some(symbol) }, + None => CodeRef { file: s, symbol: None }, + } +} + +/// Does this source string look like an in-repo code ref (vs an official-doc URL)? +pub fn is_code_ref(s: &str) -> bool { + !s.starts_with("http://") && !s.starts_with("https://") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn code_ref_parsing() { + let r = parse_code_ref("crates/a/src/b.rs#foo"); + assert_eq!(r.file, "crates/a/src/b.rs"); + assert_eq!(r.symbol, Some("foo")); + assert_eq!(parse_code_ref("crates/a/src/b.rs").symbol, None); + assert!(is_code_ref("crates/a.rs")); + assert!(!is_code_ref("https://example.com")); + } + + #[test] + fn rejects_out_of_set_enum() { + let toml = "kind = \"carrier_pigeon\"\nnote = \"x\"\nsource = \"y\""; + assert!(toml::from_str::(toml).is_err()); + } + + #[test] + fn rejects_unknown_field() { + let toml = "supported = true\nnote = \"x\"\nsource = \"y\"\nbogus = 1"; + assert!(toml::from_str::(toml).is_err()); + } + + #[test] + fn status_source_requirement() { + assert!(Status::Implemented.requires_source()); + assert!(!Status::Na.requires_source()); + } +} diff --git a/crates/platform-schema/tests/conformance.rs b/crates/platform-schema/tests/conformance.rs new file mode 100644 index 000000000..80b33d293 --- /dev/null +++ b/crates/platform-schema/tests/conformance.rs @@ -0,0 +1,197 @@ +//! Conformance tests for `docs/platforms/schema/*.toml`. +//! +//! Run against the real repo tree: every platform file is deserialized into the +//! authoritative structs (that IS the structural/enum check), the closed feature +//! set + schema version are enforced, and every code-ref `source` is checked to +//! still point at a file (and symbol) that exists — so the docs can't drift. + +use platform_schema::*; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +/// The 8 platforms that must have a schema file. +const EXPECTED_PLATFORMS: &[&str] = &[ + "line", "slack", "telegram", "discord", "feishu", "wecom", "googlechat", "teams", +]; + +fn repo_root() -> PathBuf { + // CARGO_MANIFEST_DIR = /crates/platform-schema -> up 2 = + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("repo root") + .to_path_buf() +} + +fn schema_dir() -> PathBuf { + repo_root().join("docs/platforms/schema") +} + +/// Deserialize every schema/*.toml. A parse/enum/missing-field/unknown-field +/// error panics here — that IS the structural-validity check. +fn load_all() -> Vec<(String, Platform)> { + let dir = schema_dir(); + let mut out = Vec::new(); + let entries = + fs::read_dir(&dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display())); + for entry in entries { + let path = entry.unwrap().path(); + if path.extension().and_then(|e| e.to_str()) != Some("toml") { + continue; + } + let name = path.file_stem().unwrap().to_string_lossy().into_owned(); + let text = fs::read_to_string(&path).unwrap(); + let platform: Platform = toml::from_str(&text) + .unwrap_or_else(|e| panic!("{} failed to deserialize: {e}", path.display())); + out.push((name, platform)); + } + out +} + +#[test] +fn all_expected_platform_files_present() { + let present: BTreeSet = load_all().into_iter().map(|(n, _)| n).collect(); + let missing: Vec<_> = EXPECTED_PLATFORMS + .iter() + .filter(|p| !present.contains(**p)) + .collect(); + assert!(missing.is_empty(), "missing schema files for: {missing:?}"); +} + +#[test] +fn schema_version_is_current() { + for (name, p) in load_all() { + assert_eq!( + p.schema_version, SCHEMA_VERSION, + "{name}.toml: schema_version {} != current {SCHEMA_VERSION} (stale page)", + p.schema_version + ); + } +} + +#[test] +fn platform_name_matches_filename() { + for (name, p) in load_all() { + assert_eq!( + p.platform.name, name, + "{name}.toml: [platform].name is {:?}, must match the filename", + p.platform.name + ); + } +} + +#[test] +fn feature_set_is_exactly_the_closed_set() { + let want: BTreeSet<&str> = EXPECTED_FEATURES.iter().copied().collect(); + for (name, p) in load_all() { + let mut seen: BTreeSet = BTreeSet::new(); + for f in &p.openab_features { + assert!( + want.contains(f.feature.as_str()), + "{name}.toml: unknown feature key {:?}", + f.feature + ); + assert!( + seen.insert(f.feature.clone()), + "{name}.toml: duplicate feature {:?}", + f.feature + ); + } + let got: BTreeSet<&str> = seen.iter().map(String::as_str).collect(); + assert_eq!( + got, want, + "{name}.toml: feature set mismatch; missing {:?}", + want.difference(&got).collect::>() + ); + } +} + +#[test] +fn present_features_cite_a_source() { + for (name, p) in load_all() { + for f in &p.openab_features { + if f.status.requires_source() { + assert!( + !f.source.is_empty(), + "{name}.toml: feature {:?} claims presence but cites no source", + f.feature + ); + } + } + } +} + +/// The core anti-drift check: every feature code-ref points at a real file, and +/// every `#symbol` actually appears in it. +#[test] +fn feature_sources_exist_in_tree() { + let root = repo_root(); + let mut errs = Vec::new(); + for (name, p) in load_all() { + for f in &p.openab_features { + for src in &f.source { + if !is_code_ref(src) { + errs.push(format!( + "{name}.toml feature {:?}: source {src:?} is a URL, expected a file ref", + f.feature + )); + continue; + } + if let Err(msg) = check_code_ref(&root, src) { + errs.push(format!("{name}.toml feature {:?}: {msg}", f.feature)); + } + } + } + } + assert!(errs.is_empty(), "dead feature sources:\n {}", errs.join("\n ")); +} + +#[test] +fn quirk_code_sources_exist_in_tree() { + let root = repo_root(); + let mut errs = Vec::new(); + for (name, p) in load_all() { + for q in &p.quirks { + if let Some(src) = &q.source { + if is_code_ref(src) { + if let Err(msg) = check_code_ref(&root, src) { + errs.push(format!("{name}.toml quirk {:?}: {msg}", q.title)); + } + } + } + } + } + assert!(errs.is_empty(), "dead quirk sources:\n {}", errs.join("\n ")); +} + +fn check_code_ref(root: &Path, src: &str) -> Result<(), String> { + let r = parse_code_ref(src); + let path = root.join(r.file); + if !path.is_file() { + return Err(format!("source file {:?} does not exist", r.file)); + } + if let Some(sym) = r.symbol { + let text = fs::read_to_string(&path).map_err(|e| e.to_string())?; + if !text.contains(sym) { + return Err(format!("symbol {sym:?} not found in {:?} (renamed/deleted?)", r.file)); + } + } + Ok(()) +} + +/// The template must keep enumerating every capability section + feature key, so +/// a struct change can't silently leave the human-facing template behind. +#[test] +fn template_enumerates_every_section_and_feature() { + let text = fs::read_to_string(repo_root().join("docs/platforms/_template.toml")) + .expect("read _template.toml"); + for section in CAPABILITY_SECTIONS { + let header = format!("[capability.{section}]"); + assert!(text.contains(&header), "_template.toml missing {header}"); + } + for feature in EXPECTED_FEATURES { + let key = format!("feature = \"{feature}\""); + assert!(text.contains(&key), "_template.toml missing feature block for {feature}"); + } +} diff --git a/docs/platforms/README.md b/docs/platforms/README.md new file mode 100644 index 000000000..3e91e4241 --- /dev/null +++ b/docs/platforms/README.md @@ -0,0 +1,192 @@ +# Messaging platforms — schema & index + +Engineering/reviewer-facing knowledge base for how each messaging platform behaves and how OpenAB maps it. **Distinct from** the operator setup guides in `docs/.md`. + +This directory is **not a giant table** — it defines a **schema** that every platform fills in its own `schema/.toml`. The files are the machine-checked source of truth, validated by the `crates/platform-schema` conformance tests (run in CI). See [`_template.toml`](./_template.toml) for the blank schema + per-field docs. + +## How it works + +Each `schema/.toml` has three schema-driven parts: + +1. **Platform capability** (`[capability.*]`, fixed fields) — the platform's intrinsic nature and what a bot can/can't do inside it. Same fields for every platform. Source of truth = **official docs** (each field carries a `source` URL). +2. **OpenAB feature support** (`[[openab_features]]`, the closed 17-feature set) — for each OpenAB capability, a `status` + note + code `source`. Source of truth = **our code + the PR that decided it**. +3. **Platform quirks** (`[[quirks]]`, freeform dated log) — anything that doesn't fit a fixed field (e.g. LINE's reply/push model), plus a findings log. + +**Sourcing rule:** attach the source that answers *"why should I trust or keep this?"* — intrinsic `(A)` facts link the **official platform doc** (a `source` URL); OpenAB `(B)` decisions/findings point at the **code** (`file.rs#symbol`) and, where relevant, the **PR** (`pr` / `refs`). Code refs use a grep-stable `#symbol` (no line numbers), so conformance can confirm they still exist without breaking on unrelated edits above the target. + +## Conformance + +`crates/platform-schema` deserializes every `schema/*.toml` into typed structs and, in CI, enforces: + +- **structural validity** — required fields, closed enum sets, the exact 17-feature set, unknown-key rejection; +- **version currency** — every file's `schema_version` matches the current one (a stale file fails the build); +- **anti-drift** — every `source` code-ref still resolves to a real file + `#symbol` in the tree. + +- **Current schema version: `2026-07-08`** — the top-line `schema_version` in each file. Bump it when the schema changes; the conformance test then flags every file that hasn't been re-verified. + +## Platforms + +| Platform | Schema file | +|---|---| +| line | [schema/line.toml](./schema/line.toml) | +| slack | [schema/slack.toml](./schema/slack.toml) | +| telegram | [schema/telegram.toml](./schema/telegram.toml) | +| discord | [schema/discord.toml](./schema/discord.toml) | +| feishu | [schema/feishu.toml](./schema/feishu.toml) | +| wecom | [schema/wecom.toml](./schema/wecom.toml) | +| googlechat | [schema/googlechat.toml](./schema/googlechat.toml) | +| teams | [schema/teams.toml](./schema/teams.toml) | + +--- + +## Schema reference + +The authoritative field list + types live in [`_template.toml`](./_template.toml) and the structs in `crates/platform-schema/src/lib.rs`. Summary below. + +### 1 — `[capability.*]` (platform-capability) + +Fixed fields, same for every platform; each carries a typed value + `note` + official-doc `source`. Use `?` in a note only when a fact is genuinely unverified. + +| Section | Meaning / allowed values | +|---|---| +| `transport` | how events arrive: `webhook` / `websocket` / `socket_mode` / `long_poll` | +| `inbound_auth` | L1 request-auth / signature scheme: `hmac_sha256` / `jwt_rs256` / `aes` / `shared_secret` / `oauth` / `none` | +| `threads` | `native` / `reply_to_only` / `emulated` / `none` | +| `slash_commands` | supported? how registered / delivered? | +| `mentions` | how the bot detects being addressed: `at_mention` / `username` / `self_flag` / `none` | +| `emoji_reactions` | can a bot **add** / **remove** reactions? does it **receive** reaction events? | +| `edit_message` | can a bot edit its own already-sent message? | +| `delete_message` | can a bot delete a message? scope: `none` / `own` / `others` / `own_and_others` | +| `rich_content` | markdown / cards / buttons support | +| `attachments` | inbound & outbound media types (`image`/`audio`/`video`/`file`) + size cap | +| `message_length_limit` | max chars per outbound message (chunking implication) | +| `dm_support` | 1:1 direct messages supported? | +| `group_model` | group / channel / room / space taxonomy | +| `group_sender_identity` | stable per-user sender id in group events: `yes` / `no` / `consent_gated` | +| `send_model` | `any_time` / `reply_only` / `push_only` / `hybrid`; reply-token TTL; batch cap | +| `proactive_push` | can the bot message unsolicited? quota model: `unlimited` / `metered` / `none` | +| `bot_to_bot` | does the platform deliver other bots' messages to this bot? | +| `typing_indicator` | supported? | + +### 2 — `[[openab_features]]` (openab-feature-support) + +The closed set of OpenAB capabilities (derived from the `ChatAdapter` trait in `crates/openab-core/src/adapter.rs` + the trust/ingress layer). Each block: `feature` + `status` + `note` + `source` (array of `file.rs#symbol`) + optional `pr`. + +**Status enum:** `implemented` · `partial` · `workaround` · `not_implemented` · `n_a` (platform can't support it). Always explain `workaround` / `partial` — that "why" is the valuable part. + +| Feature key | Covers | +|---|---| +| `send_message` | basic outbound | +| `message_split` | long-message handling (`split_delivery`) | +| `streaming` | `stream_begin` / `stream_append` / `stream_finish` — live vs batched | +| `reply_quote` | `send_message_with_reply` | +| `edit_message` | own-message edit | +| `delete_message` | delete own / others | +| `emoji_reactions` | `add_reaction` / `remove_reaction` | +| `threads_topics` | `create_thread` / `create_topic` | +| `media_inbound` | images / files / audio ingestion | +| `voice_stt` | speech-to-text on voice notes | +| `trust_gate` | allowlist / identity-trust enforcement point | +| `deny_echo` | reply-on-deny behavior + delivery constraints | +| `mention_gating` | require @mention in groups | +| `slash_commands` | `/reset`, `/cancel` handling | +| `multibot` | multiple bots in one channel | +| `group_routing` | group / session routing | +| `cron_dispatch` | scheduled cron job delivery via `cronjob.toml` | + +### 3 — `[[quirks]]` (platform-quirks) + +Freeform dated log — anything not captured by sections 1/2 (special models, gotchas, structural constraints) plus a findings trail. Each block: + +- `date` (`YYYY-MM-DD`), `title`, `note` (prose) — required. +- `kind` (required): `intrinsic` (a platform fact) or `openab_decision` (a choice/finding of ours). +- `source` (optional): official-doc URL, or `file.rs` / `file.rs#symbol`. +- `refs` (optional): PR/ADR links, e.g. `["#1291"]`. + +--- + +## How it all fits together + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ docs/platforms/schema/*.toml (machine-readable facts, source of truth)│ +│ │ +│ line.toml │ slack.toml │ discord.toml │ telegram.toml │ feishu.toml │…│ +└──────┬──────────────┬──────────────┬────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────┐ ┌──────────┐ ┌───────────────────────────────────────────┐ +│ CI │ │ Onboard │ │ Future │ +│ │ │ │ │ │ +│ conformance │ │ new │ │ • runtime capability queries │ +│ tests │ │ maintainer│ │ • auto-generated comparison tables │ +│ (Rust crate)│ │ reads │ │ • adapter scaffolding from template │ +│ │ │ schema │ │ │ +└──────┬──────┘ └──────────┘ └───────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Validates: │ +│ • structural correctness (serde) │ +│ • closed 17-feature set (no gaps) │ +│ • schema version freshness │ +│ • code-ref #symbol still exists in tree│ +└─────────────────────────────────────────┘ + +Separate layer (human-facing): +┌─────────────────────────────────────────┐ +│ docs/.md │ +│ (operator setup guides — how to deploy)│ +│ NOT duplicated in TOML; lives alongside│ +└─────────────────────────────────────────┘ +``` + +--- + +## How to update + +### Adding a new feature to the closed set + +When OpenAB gains a new capability (e.g. `voice_call`), update in **one PR**: + +1. **`crates/platform-schema/src/lib.rs`** — add to `EXPECTED_FEATURES` +2. **`docs/platforms/_template.toml`** — add a `[[openab_features]]` block +3. **`docs/platforms/README.md`** — add a row to the feature table above +4. **All 8 `schema/*.toml` files** — add the feature block with appropriate status: + ```toml + [[openab_features]] + feature = "voice_call" + status = "not_implemented" # or implemented / partial / workaround / n_a + note = "..." + source = ["crates/openab-gateway/src/adapters/line.rs#handle_voice"] + pr = "#XXXX" + ``` +5. **Bump `SCHEMA_VERSION`** in `lib.rs` + update `schema_version` in all `.toml` files + +CI enforces completeness: a missing feature block in any platform file fails the build. + +### Changing a feature's status on one platform + +When an adapter adds or drops support (e.g. LINE gains streaming): + +1. Edit only that platform's `schema/.toml` +2. Update `status`, `note`, `source`, and `pr` fields +3. No other files need to change (no version bump required for status-only changes) + +### Adding a new platform + +1. Copy `_template.toml` to `schema/.toml` +2. Fill all `[capability.*]` sections (source: official platform docs) +3. Fill all 17 `[[openab_features]]` blocks (source: adapter code) +4. Add `[[quirks]]` for platform-specific behaviors +5. Add the platform to `EXPECTED_PLATFORMS` in `tests/conformance.rs` +6. Add a row to the Platforms table in this README + +### Architecture: TOML vs Markdown + +| Layer | Purpose | Audience | +|-------|---------|----------| +| `docs/platforms/schema/*.toml` | Machine-readable facts schema | CI, automation, onboarding | +| `docs/.md` | Human-readable setup/operator guide | Operators deploying OAB | + +These are **complementary, not overlapping**. TOML captures "what the platform can do + what OpenAB implements"; Markdown captures "how to configure and deploy". Update the TOML when adapter behavior changes; update the Markdown when deployment instructions change. diff --git a/docs/platforms/_template.toml b/docs/platforms/_template.toml new file mode 100644 index 000000000..23e6dbc78 --- /dev/null +++ b/docs/platforms/_template.toml @@ -0,0 +1,315 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Platform capability schema — TEMPLATE & human-readable schema reference +# ═══════════════════════════════════════════════════════════════════════════ +# +# Copy this file to schema/.toml (filename = platform name, +# snake_case) and fill every field. +# +# This template IS the schema documentation. The authoritative *types* live in +# the Rust structs at `crates/platform-schema/src/lib.rs` (serde). Conformance +# fully deserializes every real schema/*.toml into those structs, and checks +# this template still enumerates every capability section + every feature key — +# so if the structs gain/rename a field, this template must be updated too. +# +# Three schemas, matching docs/platforms/README.md: +# [capability.*] -> Schema 1 platform-capability (source: official docs) +# [[openab_features]] -> Schema 2 openab-feature-support (source: our code + PR) +# [[quirks]] -> Schema 3 platform-quirks (freeform dated log) +# +# Sourcing rule: +# (A) intrinsic facts -> `source` is an official-doc URL +# (B) OpenAB decisions -> `source` points to a file, NOT a line number: +# "crates/.../file.rs" (file only), or +# "crates/.../file.rs#symbol" (file + a fn/const name for precision) +# Line numbers are deliberately avoided — they go stale on any edit above. +# A symbol name is stable (only churns when renamed/deleted — exactly the +# drift we want to catch) and is greppable, so conformance can verify it. +# +# Conventions: +# required = must be present, deserialize fails otherwise +# optional = may be omitted (Rust Option / #[serde(default)]) +# enum = closed set; any other value fails to deserialize +# Use "?" in a note (not as a value) when a fact is genuinely unverified. +# ═══════════════════════════════════════════════════════════════════════════ + +# Version this file was written against. Bumped when the schema changes. +# Conformance flags any schema/*.toml older than the current version as stale. +schema_version = "2026-07-08" # required, "YYYY-MM-DD" + +[platform] +name = "" # required. matches filename, snake_case +official_docs = "" # required. URL to the platform's official docs root +description = "" # required. one-line "what is this platform" + + +# ═══ Schema 1 — platform-capability (source of truth: official docs) ═════════ +# Every field below is required. Each carries a typed value (bool/enum/int) for +# machine comparison + a freeform `note` + an official-doc `source` URL. +# +# How to fill each block: +# - Set the typed value(s) to the platform's actual behavior. +# - `note`: the concrete platform-specific detail a reviewer needs (the "how"). +# - `source`: the official-doc URL that proves the fact (one canonical link). +# - If a fact is genuinely unverified, still set the typed value to the best +# known guess and flag it in `note` with a leading "?" so it's auditable. + +[capability.transport] +# enum: webhook | websocket | socket_mode | long_poll +kind = "webhook" # required +note = "" # required. how events arrive +source = "" # required. official-doc URL + +[capability.inbound_auth] +# enum: hmac_sha256 | jwt_rs256 | aes | shared_secret | oauth | none +scheme = "hmac_sha256" # required. L1 request-auth / signature scheme +note = "" # required. e.g. "HMAC over raw body vs x-line-signature" +source = "" # required + +[capability.threads] +# enum: native | reply_to_only | emulated | none +model = "none" # required +note = "" # required. the thread/topic model, if any +source = "" # required + +[capability.slash_commands] +supported = false # required, bool. platform-level command API? +note = "" # required. registration / delivery, or "plain text only" +source = "" # required + +[capability.mentions] +# how the bot knows it was addressed. enum: at_mention | username | self_flag | none +method = "at_mention" # required +note = "" # required. the concrete detection mechanism (e.g. isSelf flag) +source = "" # required + +[capability.emoji_reactions] +bot_can_add = false # required, bool +bot_can_remove = false # required, bool +bot_receives_events = false # required, bool. does the bot get reaction webhooks? +note = "" # required +source = "" # required + +[capability.edit_message] +supported = false # required, bool. can a bot edit its own sent message? +max_edits = 0 # optional, int. cap on edits per message (0/omit = unspecified/unlimited) +note = "" # required +source = "" # required + +[capability.delete_message] +supported = false # required, bool +# enum: none | own | others | own_and_others +scope = "none" # required. what a bot may delete +window_sec = 0 # optional, int. deletion time window in seconds (0/omit = none/unlimited) +note = "" # required +source = "" # required + +[capability.rich_content] +markdown = false # required, bool +cards = false # required, bool. cards / templates / flex +buttons = false # required, bool. interactive buttons +note = "" # required. the concrete rich-message types +source = "" # required + +[capability.attachments] +inbound = [] # required. subset of: image audio video file (what CAN arrive) +outbound = [] # required. subset of: image audio video file +max_size_mb = 0 # optional, int. headline cap; 0/omit = unspecified +max_count = 0 # optional, int. max attachments per message (0/omit = unspecified) +# outbound_delivery = "upload" # optional, enum: url | upload — how the bot SENDS media +# required. fetch model, provider caveats, expiry — AND per-type / per-plan size +# limits when they differ from the single headline max_size_mb above. +note = "" +source = "" # required + +[capability.message_length_limit] +max_chars = 0 # required, int. hard cap per outbound text object +note = "" # required. counting unit (e.g. UTF-16 code units) +source = "" # required + +[capability.dm_support] +supported = false # required, bool. 1:1 direct messages? +note = "" # required +source = "" # required + +[capability.group_model] +kinds = [] # required. taxonomy, e.g. ["group","room","user"] +note = "" # required +source = "" # required + +[capability.group_sender_identity] +# enum: yes | no | consent_gated — is a stable per-user id available in group events? +stable_id = "no" # required +note = "" # required. when present/absent, consent gating +source = "" # required + +[capability.send_model] +# enum: any_time | reply_only | push_only | hybrid +model = "any_time" # required +reply_token_ttl_sec = 0 # optional, int. only if reply-token based (e.g. LINE ~60) +max_objects_per_send = 0 # optional, int. batch cap per request, if any +note = "" # required. reply-vs-push economics / windows +source = "" # required + +[capability.proactive_push] +supported = false # required, bool. can the bot message unsolicited? +# enum: unlimited | metered | none +quota_model = "none" # required +note = "" # required. quota / rate-limit specifics +source = "" # required + +[capability.bot_to_bot] +delivered = false # required, bool. does the platform deliver other bots' msgs? +note = "" # required +source = "" # required + +[capability.typing_indicator] +supported = false # required, bool +note = "" # required. scope / rate limits / semantics +source = "" # required + + +# ═══ Schema 2 — openab-feature-support (source of truth: our code + PR) ══════ +# One [[openab_features]] block per OpenAB capability. All 17 blocks below are +# the complete, closed feature set (conformance checks every one is present and +# that no unknown feature key is used). Fill each for the platform. +# +# status enum: implemented | partial | workaround | not_implemented | n_a +# Always explain partial / workaround / limited — the "why" is the value. +# `source` is an array of "file.rs" or "file.rs#symbol" (no line numbers) — +# conformance verifies each file exists, and greps for the #symbol if given. +# `pr` is optional (the deciding PR/issue). +# (status/note/source left blank here — this is a blank form to copy & fill.) + +[[openab_features]] +feature = "send_message" # basic outbound +status = "" +note = "" +source = [] # e.g. ["crates/.../line.rs#dispatch_line_reply"] +pr = "" + +[[openab_features]] +feature = "message_split" # long-message handling (split_delivery / message_limit) +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "streaming" # stream_begin / stream_append / stream_finish — live vs batched +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "reply_quote" # send_message_with_reply +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "edit_message" # own-message edit +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "delete_message" # delete own / others +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" # add_reaction / remove_reaction +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "threads_topics" # create_thread / create_topic +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "media_inbound" # images / files / audio ingestion +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "voice_stt" # speech-to-text on voice notes +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "trust_gate" # allowlist / identity-trust enforcement point +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "deny_echo" # reply-on-deny behavior + delivery constraints +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "mention_gating" # require @mention in groups +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "slash_commands" # /reset, /cancel handling +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "multibot" # multiple bots in one channel +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "group_routing" # group / session routing +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "cron_dispatch" # scheduled cronjob.toml jobs targeting this platform +status = "" +note = "" +source = [] # e.g. ["crates/openab-core/src/cron.rs#VALID_PLATFORMS"] +pr = "" + + +# ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ +# One [[quirks]] block per gotcha / special model / structural constraint. +# `note` is prose and is NOT machine-checked. `source` and `refs` are optional; +# when present, conformance shell-checks them (URL/file:line exists, PR exists). + +[[quirks]] +date = "" # required, "YYYY-MM-DD" — when documented +title = "" # required. short headline +note = "" # required. freeform prose (may be multi-paragraph) +# required, enum: intrinsic (a platform fact) | openab_decision (a choice/finding of ours) +kind = "intrinsic" +source = "" # optional. official-doc URL, or "file.rs" / "file.rs#symbol" +refs = [] # optional. ["#1291", ...] PR/ADR links diff --git a/docs/platforms/schema/discord.toml b/docs/platforms/schema/discord.toml new file mode 100644 index 000000000..44bdc80bf --- /dev/null +++ b/docs/platforms/schema/discord.toml @@ -0,0 +1,355 @@ +# Discord platform capability schema. +# Generated from the adapter source + official docs — follows _template.toml. + +schema_version = "2026-07-08" + +[platform] +name = "discord" +official_docs = "https://docs.discord.com/developers/docs/intro" +description = "Discord bot adapter over the persistent WebSocket Gateway + REST HTTP API (serenity-based, in openab-core)." + + +# ═══ Schema 1 — platform-capability (source: official docs) ══════════════════ + +[capability.transport] +kind = "websocket" +note = "Persistent WSS Gateway (wss://gateway.discord.gg/): the bot opens a persistent gateway connection and receives events; outbound actions use the REST HTTP API." +source = "https://docs.discord.com/developers/topics/gateway" + +[capability.inbound_auth] +scheme = "none" +note = "Gateway handshake via Identify (opcode 2) carrying the bot token + intents; REST calls use `Authorization: Bot `. Gateway is a bot-initiated persistent socket, so there is no per-event inbound signature to verify (unlike webhook platforms)." +source = "https://docs.discord.com/developers/topics/gateway" + +[capability.threads] +model = "native" +note = "ANNOUNCEMENT_THREAD (10) / PUBLIC_THREAD (11) / PRIVATE_THREAD (12) — temporary sub-channels of a text/forum/announcement channel (API v9+). Threads are themselves channels (own channel_id, thread_metadata, parent_id)." +source = "https://docs.discord.com/developers/resources/channel" + +[capability.slash_commands] +supported = true +note = "Application commands of type CHAT_INPUT (1), registered over HTTP: global POST /applications/{id}/commands or per-guild. Invocations delivered as INTERACTION_CREATE with an interaction token for the response." +source = "https://docs.discord.com/developers/interactions/application-commands" + +[capability.mentions] +method = "at_mention" +note = "Bot detects being addressed via user mention <@user_id> (and legacy nick form <@!id>) or role mention <@&role_id> in mention_roles. Gateway also flags mentions / mention_everyone." +source = "https://docs.discord.com/developers/resources/message" + +[capability.emoji_reactions] +bot_can_add = true +bot_can_remove = true +bot_receives_events = true +note = "Add: PUT Create Reaction. Remove: Delete Own Reaction (own) or delete others' with MANAGE_MESSAGES. Receives MESSAGE_REACTION_ADD / MESSAGE_REACTION_REMOVE (needs GUILD_MESSAGE_REACTIONS intent 1<<10; DIRECT_MESSAGE_REACTIONS for DMs)." +source = "https://docs.discord.com/developers/resources/message" + +[capability.edit_message] +supported = true +note = "A bot may edit its own messages via Edit Message." +source = "https://docs.discord.com/developers/resources/message" + +[capability.delete_message] +supported = true +scope = "own_and_others" +note = "Own: always. Others': requires MANAGE_MESSAGES permission." +source = "https://docs.discord.com/developers/resources/message" + +[capability.rich_content] +markdown = true +cards = true +buttons = true +note = "Markdown, embeds, and message components (buttons, string/select menus, action rows)." +source = "https://docs.discord.com/developers/resources/message" + +[capability.attachments] +inbound = ["image", "audio", "video", "file"] +outbound = ["image", "audio", "video", "file"] +max_size_mb = 10 +max_count = 10 +outbound_delivery = "upload" +note = "Inbound & outbound arbitrary file types. Default upload cap 10 MiB per file (raised by uploader Nitro status or server Boost tier)." +source = "https://docs.discord.com/developers/reference" + +[capability.message_length_limit] +max_chars = 2000 +note = "2000 characters per message content." +source = "https://docs.discord.com/developers/resources/channel" + +[capability.dm_support] +supported = true +note = "1:1 DM channels (private channels)." +source = "https://docs.discord.com/developers/resources/channel" + +[capability.group_model] +kinds = ["guild", "channel", "thread", "dm", "group_dm"] +note = "Guild → channels (GUILD_TEXT, forum, announcement, voice) → threads. Plus DM / group-DM private channels. Threads are channels with a parent_id." +source = "https://docs.discord.com/developers/resources/channel" + +[capability.group_sender_identity] +stable_id = "yes" +note = "Stable per-user snowflake author.id on every message; not consent-gated. Requires the MESSAGE_CONTENT intent to also read message text." +source = "https://docs.discord.com/developers/resources/message" + +[capability.send_model] +model = "push_only" +note = "No reply-window/TTL — a bot with channel access may send at any time via REST. Replies are opt-in via message_reference{message_id}." +source = "https://docs.discord.com/developers/resources/message" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Unsolicited sends allowed within permissions. Global cap 50 requests/sec/bot; per-route buckets (X-RateLimit-Bucket); invalid-request cap 10,000/10min." +source = "https://docs.discord.com/developers/topics/rate-limits" + +[capability.bot_to_bot] +delivered = true +note = "The gateway delivers other bots' messages; the author.bot flag distinguishes them (the bot's own messages arrive too and must be self-filtered)." +source = "https://docs.discord.com/developers/topics/gateway" + +[capability.typing_indicator] +supported = true +note = "POST /channels/{id}/typing (Trigger Typing); inbound TYPING_START event." +source = "https://docs.discord.com/developers/topics/gateway" + + +# ═══ Schema 2 — openab-feature-support (source: our code + PR) ═══════════════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "ChannelId::say; resolve_channel prefers thread_id over channel_id; message_limit() = 2000." +source = ["crates/openab-core/src/discord.rs#resolve_channel", "crates/openab-core/src/discord.rs#message_limit"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "implemented" +note = "Router reads message_limit() (2000) then splits: split_delivery handles directive/body, format::split_message chunks the body, mentions are propagated to each chunk." +source = ["crates/openab-core/src/adapter.rs#split_delivery", "crates/openab-core/src/format.rs#split_message"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "implemented" +note = "Post-then-edit, not native. use_streaming returns true only when no other bot is present (!other_bot_present); the router consults it at dispatch. uses_native_streaming stays default false, so the trait's native stream methods only hit their edit-based fallbacks." +source = ["crates/openab-core/src/discord.rs#use_streaming", "crates/openab-core/src/adapter.rs#uses_native_streaming"] +pr = "#534" + +[[openab_features]] +feature = "reply_quote" +status = "implemented" +note = "send_message_with_reply sets reference_message; falls back to plain send on invalid id (parses to 0) or reply failure (unknown/cross-channel message)." +source = ["crates/openab-core/src/discord.rs#send_message_with_reply"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "implemented" +note = "Native EditMessage.content (overrides the trait default that returns \"edit_message not supported\")." +source = ["crates/openab-core/src/discord.rs#EditMessage"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "implemented" +note = "Native http.delete_message (overrides trait default which edits to a zero-width space \\u{200b})." +source = ["crates/openab-core/src/discord.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "implemented" +note = "add_reaction = create_reaction, remove_reaction = delete_reaction_me. Unicode emoji only." +source = ["crates/openab-core/src/discord.rs#add_reaction", "crates/openab-core/src/discord.rs#remove_reaction"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "implemented" +note = "create_thread builds a thread from the trigger message via serenity create_thread_from_message (1-day auto-archive); auto-thread on first channel message via get_or_create_thread. Not the gateway create_topic path — the native adapter creates threads directly." +source = ["crates/openab-core/src/discord.rs#create_thread", "crates/openab-core/src/discord.rs#get_or_create_thread"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "implemented" +note = "Attachments processed inline in the per-attachment loop: images encoded (download_and_encode_image), text files (≤1 MB total, ≤5 files), video passed as a URL block; non-image files warned to the user." +source = ["crates/openab-core/src/discord.rs#download_and_encode_image"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "implemented" +note = "Audio attachments transcribed via media::download_and_transcribe when stt_config.enabled; transcript injected + echoed; 🎤 reaction when STT disabled." +source = ["crates/openab-core/src/discord.rs"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "implemented" +note = "Two layers: adapter-level channel/user allowlist (allowed_channels, is_denied_user) + shared L3 identity gate router.gate_incoming (humans only; bots bypass via l3_gate_applies)." +source = ["crates/openab-core/src/discord.rs#is_denied_user", "crates/openab-core/src/discord.rs#l3_gate_applies", "crates/openab-core/src/adapter.rs#gate_incoming"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "partial" +note = "On a denied user the bot reacts 🚫 on the offending message and drops it — no text reply (Discord L3 denies drop silently apart from the reaction)." +source = ["crates/openab-core/src/discord.rs#is_denied_user"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "implemented" +note = "AllowUsers modes: Mentions (always require @), Involved (skip @ if bot owns/participated in thread), MultibotMentions (require @ when other bots present). DMs treated as an implicit mention." +source = ["crates/openab-core/src/discord.rs"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "implemented" +note = "Global commands registered on ready via set_global_commands: /models, /agents, /cancel, /cancel-all, /reset, /remind, /auth, /export-thread; dispatched via interaction_create." +source = ["crates/openab-core/src/discord.rs#set_global_commands", "crates/openab-core/src/discord.rs#interaction_create"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "implemented" +note = "Early other-bot detection cached (disk-persisted, irreversible); disables streaming, gates via MultibotMentions, enforces bot-turn limits; trusted_bot_ids + @mention admits handoff regardless of allow_bot_messages." +source = ["crates/openab-core/src/discord.rs#MultibotCache", "crates/openab-core/src/discord.rs#BotTurnTracker"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Per-thread dispatch keyed by dispatcher.key(\"discord\", channel_id, sender_id); thread↔parent allowlist via detect_thread; ambient mode buffers passive-channel messages." +source = ["crates/openab-core/src/discord.rs#detect_thread"] +pr = "" + +[[openab_features]] +feature = "cron_dispatch" +status = "implemented" +note = "`cronjob.toml` jobs with `platform = \"discord\"` fire via the shared scheduler: `discord` is in `VALID_PLATFORMS` and `main` registers the Discord adapter under `\"discord\"` in `cron_adapters`, so `fire_cronjob` dispatches through `adapter.send_message`. Cron dispatch intentionally bypasses the L2/L3 ingress trust gate — jobs are operator-authored in config, not untrusted inbound." +source = ["crates/openab-core/src/cron.rs#VALID_PLATFORMS", "crates/openab-core/src/cron.rs#fire_cronjob", "src/main.rs#cron_adapters"] +pr = "" + + +# ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ + +[[quirks]] +date = "2026-07-04" +title = "Threads are channels" +note = """ +A Discord thread has its own channel_id; the adapter resolves outbound targets via thread_id.unwrap_or(channel_id) (resolve_channel). Thread identity is thread_metadata.is_some() — parent_id alone is NOT reliable (category children also carry parent_id), so detect_thread returns early unless has_thread_metadata, and only uses parent_id for the allowlist check. +""" +kind = "intrinsic" +source = "crates/openab-core/src/discord.rs#detect_thread" + +[[quirks]] +date = "2026-07-04" +title = "Self-echo and bot-loop control" +note = """ +The bot receives its own messages over the gateway and must self-filter (msg.author.id == bot_id). Because multiple bots can ping-pong, there are layered guards: a hard consecutive-bot cap (MAX_CONSECUTIVE_BOT_TURNS = 1000), a configurable soft per-thread max_bot_turns reset by any human message, and BotTurnTracker. Bot-turn counting deliberately runs before the self-check so all bot messages count, but warning posts respect the channel allowlist + prior participation to avoid uninvolved bots spamming. +""" +kind = "intrinsic" +source = "crates/openab-core/src/discord.rs#BotTurnTracker" + +[[quirks]] +date = "2026-07-04" +title = "Multibot detection is irreversible & disk-cached" +note = """ +Once any other bot posts in a channel/thread, that thread is permanently "multibot": cached in-memory and persisted to MultibotCache on disk (survives restarts), since bot messages don't disappear. This flips streaming off (the edit-loop interferes across bots) and can require @mention under MultibotMentions. +""" +kind = "intrinsic" +source = "crates/openab-core/src/discord.rs#MultibotCache" + +[[quirks]] +date = "2026-07-04" +title = "Streaming is post-then-edit, not native" +note = """ +Unlike Slack, Discord has no native streaming API; OpenAB streams by editing a placeholder message. use_streaming disables this whenever another bot is present, to avoid edit interference. uses_native_streaming stays false, so the trait's native stream methods only ever hit their edit-based fallbacks. +""" +kind = "openab_decision" +source = "crates/openab-core/src/discord.rs#use_streaming" +refs = ["#534"] + +[[quirks]] +date = "2026-07-04" +title = "create_topic vs create_thread" +note = """ +The shared gateway layer has a create_topic command (gateway.rs) for gateway-protocol adapters. The native Discord adapter does NOT use it — it calls serenity create_thread_from_message directly (create_thread) and auto-creates a thread for top-level channel messages via get_or_create_thread. +""" +kind = "openab_decision" +source = "crates/openab-core/src/gateway.rs" + +[[quirks]] +date = "2026-07-04" +title = "Attachment handling caps" +note = """ +Text-file attachments are bounded independently of Discord's own 10 MiB upload cap: 1 MB total across all text files (TEXT_TOTAL_CAP) and max 5 files per message (TEXT_FILE_COUNT_CAP), enforced with a Discord-reported-size pre-check before download. Image URLs from Discord expire ~24h, which is surfaced to the agent in the injected block. +""" +kind = "openab_decision" +source = "crates/openab-core/src/discord.rs" + +[[quirks]] +date = "2026-07-04" +title = "DMs cannot hold threads" +note = """ +DM channels can't hold threads, so DMs reuse the DM channel directly and are treated as an implicit @mention; gated only by allow_dm + user allowlist (should_process_dm / should_skip_thread_creation). +""" +kind = "openab_decision" +source = "crates/openab-core/src/discord.rs#should_process_dm" + +[[quirks]] +date = "2026-07-04" +title = "Finding: default file-upload cap is 10 MiB/file" +note = """ +Default file-upload cap is 10 MiB/file (raised by Nitro/Boost); the adapter's own text-attachment caps (1 MB total / 5 files) are stricter. +""" +kind = "intrinsic" +source = "https://docs.discord.com/developers/reference" + +[[quirks]] +date = "2026-07-04" +title = "Finding: global REST rate limit is 50 req/sec/bot" +note = """ +Global REST rate limit is 50 req/sec/bot plus per-route buckets (X-RateLimit-Bucket); invalid requests capped at 10,000/10min — relevant to proactive-push and streaming edit-loop cadence. +""" +kind = "intrinsic" +source = "https://docs.discord.com/developers/topics/rate-limits" + +[[quirks]] +date = "2026-07-04" +title = "Finding: message content hard limit is 2000 chars" +note = """ +Message content hard limit is 2000 chars, matching DiscordAdapter::message_limit(); the router chunks longer replies. +""" +kind = "intrinsic" +source = "https://docs.discord.com/developers/resources/channel" + +[[quirks]] +date = "2026-07-04" +title = "Finding: thread channel types and identification" +note = """ +Thread channel types are ANNOUNCEMENT_THREAD (10) / PUBLIC_THREAD (11) / PRIVATE_THREAD (12), API v9+; identified by thread_metadata, not parent_id (category children also carry parent_id); detect_thread follows this. +""" +kind = "intrinsic" +source = "https://docs.discord.com/developers/resources/channel" + +[[quirks]] +date = "2026-07-04" +title = "Finding: transport is a bot-initiated persistent WebSocket Gateway" +note = """ +Transport is a bot-initiated persistent WebSocket Gateway (WSS) authenticated by Identify(op 2) + bot token + intents — no per-event inbound signature to verify (unlike webhook platforms). +""" +kind = "intrinsic" +source = "https://docs.discord.com/developers/topics/gateway" + +[[quirks]] +date = "2026-07-04" +title = "Finding: Section-2 ref audit (stale line refs corrected)" +note = """ +Section-2 ref audit: chunking lives in adapter.rs (split_delivery / format::split_message), not the trait def; slash-command registration is set_global_commands; is_denied_user and trusted-bot bypass live in discord.rs. Corrected stale line refs. PR # not yet assigned (was PR #TBD in the source md). +""" +kind = "openab_decision" +source = "crates/openab-core/src/discord.rs#is_denied_user" diff --git a/docs/platforms/schema/feishu.toml b/docs/platforms/schema/feishu.toml new file mode 100644 index 000000000..d52396e4b --- /dev/null +++ b/docs/platforms/schema/feishu.toml @@ -0,0 +1,380 @@ +# Feishu / Lark — platform capability schema +# Generated from the adapter source + official docs + +schema_version = "2026-07-08" + +[platform] +name = "feishu" +official_docs = "https://open.feishu.cn/document/home/index" +description = "Feishu / Lark enterprise IM bot platform (feishu.cn / larksuite.com); adapter lives in the openab-gateway crate." + + +# ═══ Schema 1 — platform-capability ══════════════════════════════════════════ + +[capability.transport] +kind = "websocket" +note = "Both: WebSocket long-connection (default; protobuf pbbp2.Frame, ACK per event) and webhook (FEISHU_CONNECTION_MODE=webhook). Ingested event: im.message.receive_v1." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/events/receive" + +[capability.inbound_auth] +scheme = "aes" +note = "Webhook L1 = SHA256(timestamp + nonce + encrypt_key + body) header signature (verify_signature) + optional constant-time verification_token; event body AES-256-CBC with key = SHA256(encrypt_key), IV = first 16 bytes of ciphertext (decrypt_event). WS: auth via AppID+AppSecret handshake to fetch endpoint." +source = "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/event-subscription-configure-/encrypt-key-encryption-configuration-case" + +[capability.threads] +model = "native" +note = "Messages carry root_id / parent_id; reply via POST /im/v1/messages/{id}/reply with optional reply_in_thread=true (default false). If parent is already a thread, replies stay in-thread automatically. parent_id for a thread always points at the thread root." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/reply" + +[capability.slash_commands] +supported = false +note = "No native slash commands. Bots have a custom menu (application.bot.menu_v6 / menu-click event; <=3 main + <=5 sub, DM-only, no group support). Command-style input is just plain text." +source = "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/bot-v3/bot-customized-menu" + +[capability.mentions] +method = "at_mention" +note = "@mention via event.message.mentions[] (each has a key placeholder like @_user_1 in text + id.open_id). Bot detects itself by matching its own open_id (resolved via /bot/v3/info)." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/events/receive" + +[capability.emoji_reactions] +bot_can_add = true +bot_can_remove = true +bot_receives_events = true +note = "Bot can add (POST /im/v1/messages/{id}/reactions) and remove (list -> find own reaction_id -> DELETE). Bot can receive reaction events via im.message.reaction.created_v1 / im.message.reaction.deleted_v1 (subscription required)." +source = "https://open.feishu.cn/document/server-docs/im-v1/message-reaction/create" + +[capability.edit_message] +supported = true +max_edits = 20 +note = "Own message only (calling identity must equal sender, else errcode 230071). PATCH /im/v1/messages/{id} updates text / post. Officially documented hard cap of 20 edits per message (errcode 230072 when exceeded); edit-time window is set by enterprise admin (errcode 230075). Card (interactive) updates use a separate endpoint, not subject to the 20-edit cap. Global API frequency limit 1000/min / 50 QPS (errcode 230020)." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/update" + +[capability.delete_message] +supported = true +scope = "own" +note = "Own message, DELETE /im/v1/messages/{id} (recall). Not subject to the edit cap. Deleting others' messages is admin/permission-gated, not a bot capability." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/delete" + +[capability.rich_content] +markdown = false +cards = true +buttons = true +note = "post (rich text: text/link/at/img/code_block), interactive CardKit v2 cards (buttons, markdown, tables, streaming). Plain markdown is NOT natively rendered as a text message — must be converted to post or a card." +source = "https://open.feishu.cn/document/server-docs/im-v1/message-content-description/create_json" + +[capability.attachments] +inbound = ["image", "audio", "video", "file"] +outbound = ["image", "file"] +max_size_mb = 100 +note = "Inbound: image / file / audio / media / post-embedded images; downloaded via /im/v1/messages/{id}/resources/{key}. Outbound: images/files uploaded first, then referenced by key. ? Feishu file-size limits are large (image ~10 MB, file ~30-100 MB tier-dependent) — exact tiers unverified." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/get-2" + +[capability.message_length_limit] +max_chars = 150000 +note = "text <= 150 KB; post / card <= 30 KB (per content JSON; card templates / style tags can inflate rendered size beyond the request body). Cap here counts bytes of the text content JSON, not chars." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/create" + +[capability.dm_support] +supported = true +note = "Yes — chat_type = \"p2p\"." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/events/receive" + +[capability.group_model] +kinds = ["group", "user"] +note = "chat (group). Event chat_type: p2p (DM) vs group; each has a stable chat_id (oc_...). No separate channel/thread taxonomy — threads are message-level (root_id)." +source = "https://open.feishu.cn/document/server-docs/group/chat/chat-overview" + +[capability.group_sender_identity] +stable_id = "yes" +note = "Stable & always present. sender.sender_id.open_id (ou_...) is a per-app stable user id delivered in every group event; not consent-gated (display-name lookup via Contact API may need scope)." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/events/receive" + +[capability.send_model] +model = "push_only" +note = "Push model — POST /im/v1/messages (or /reply) with a tenant_access_token (TTL ~7200 s, auto-refreshed). No per-message reply-window / token TTL like LINE; any message can be sent as long as the bot is in scope / in the group. The reply API's uuid is only a 1-hour idempotency window, not a reply deadline." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/create" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Yes, to users within the bot's availability scope and groups the bot has joined (with speaking permission). Rate limits: 5 QPS per same-user, 5 QPS shared per same-group, global 1000/min / 50 QPS. User opt-out -> errcode 230053; user outside availability scope / disabled -> errcode 230013. No hard daily quota." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/create" + +[capability.bot_to_bot] +delivered = false +note = "Effectively no. Feishu does NOT push another bot's messages to a bot's WebSocket, and marks other bots' messages as sender_type=\"user\", so a peer bot is indistinguishable without an explicit id allowlist. (Platform docs are silent on bot->bot event delivery — behavior verified only via the adapter, not an official statement.)" +source = "https://open.feishu.cn/document/server-docs/im-v1/faq" + +[capability.typing_indicator] +supported = false +note = "Not exposed to bots — no public typing/inputting API is documented (Message FAQ is silent on it). OpenAB signals progress with emoji reactions / a streaming card instead. (Absence confirmed by omission; cannot be stated as an affirmative platform guarantee.)" +source = "https://open.feishu.cn/document/server-docs/im-v1/faq" + + +# ═══ Schema 2 — openab-feature-support ═══════════════════════════════════════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "Replies sent as post (rich text) so markdown renders; send_post_message. send_text_message kept only for webhook fallback/tests (#[allow(dead_code)])." +source = ["crates/openab-gateway/src/adapters/feishu.rs#send_post_message", "crates/openab-gateway/src/adapters/feishu.rs#send_text_message"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "implemented" +note = "Long replies chunked at message_limit (default 4000, FEISHU_MESSAGE_LIMIT) via split_text (UTF-8-safe, breaks on newline/space); partial-chunk failure is reported as failure to core." +source = ["crates/openab-gateway/src/adapters/feishu.rs#split_text"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "workaround" +note = "No native content-streaming API. Emulated by PATCH-editing a post in place, with an auto post->card promotion: StreamingMode::Auto (default) promotes to a CardKit v2 streaming card when text is large (card_promote_bytes, default 4000), or contains a code fence / markdown table (should_use_card), or when the 20-edit post cap is hit (in handle_card_edit). post mode = kill-switch. Idle reaper finalizes cards." +source = ["crates/openab-gateway/src/adapters/feishu.rs#should_use_card", "crates/openab-gateway/src/adapters/feishu.rs#handle_card_edit", "crates/openab-gateway/src/adapters/feishu.rs#run_idle_reaper"] +pr = "" + +[[openab_features]] +feature = "reply_quote" +status = "implemented" +note = "send_message_with_reply -> gateway quote_message_id; adapter prefers quote_message_id over thread_id, uses /messages/{id}/reply, and falls back to a plain send if reply-to fails." +source = ["crates/openab-core/src/gateway.rs#send_message_with_reply", "crates/openab-gateway/src/adapters/feishu.rs"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "implemented" +note = "Real in-place PATCH (edit_feishu_message). Core overrides the trait default (which returns \"unsupported\") because Feishu acks writes: edit_message uses an 800 ms request/response so it can see cap signals. Preemptive local cap at 18 edits (FEISHU_EDIT_CAP, 2-edit safety margin) + server errcode 230072 detection." +source = ["crates/openab-gateway/src/adapters/feishu.rs#edit_feishu_message", "crates/openab-core/src/adapter.rs#edit_message"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "implemented" +note = "Native DELETE /im/v1/messages/{id} (delete_feishu_message); core overrides the trait default (edit-to-zero-width) because Feishu has a real delete. Used to remove the half-edited placeholder during cap-recovery / card-promotion." +source = ["crates/openab-gateway/src/adapters/feishu.rs#delete_feishu_message", "crates/openab-core/src/adapter.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "partial" +note = "add_reaction implemented; remove_reaction implemented via list->match-own-reaction_id->DELETE. Limited to an 8-emoji hard-coded map (eyes/thinking/fire/technologist/zap/ok/thumbsup/scream); anything else is silently dropped. Inbound reaction events are NOT ingested (adapter only parses im.message.receive_v1)." +source = ["crates/openab-gateway/src/adapters/feishu.rs#emoji_to_feishu_reaction", "crates/openab-gateway/src/adapters/feishu.rs#add_reaction", "crates/openab-gateway/src/adapters/feishu.rs#remove_reaction"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "partial" +note = "Native thread replies work (via root_id / reply). But the gateway create_topic command (from core create_thread) is a no-op — the Feishu adapter explicitly skips it and core falls back to replying in the same channel." +source = ["crates/openab-gateway/src/adapters/feishu.rs", "crates/openab-core/src/gateway.rs#create_thread"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "implemented" +note = "Images (resized to <=1200 px, JPEG q75, <=10 MB; GIF passthrough), text files (extension-allowlisted, <=512 KB), audio (<=25 MB, Whisper cap), and post-embedded images. Oversized/unsupported -> Attachment::rejected." +source = ["crates/openab-gateway/src/adapters/feishu.rs#download_feishu_image", "crates/openab-gateway/src/adapters/feishu.rs#download_feishu_file", "crates/openab-gateway/src/adapters/feishu.rs#download_feishu_audio"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "partial" +note = "Adapter only downloads audio into an audio attachment (<=25 MB); actual speech-to-text is done downstream (Whisper) by core, not in the adapter." +source = ["crates/openab-gateway/src/adapters/feishu.rs#download_feishu_audio"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "implemented" +note = "Two layers: adapter-side allowed_users / allowed_groups allowlists + bot/self filtering in parse_message_event; core-side shared ingress gate_incoming (L2 scope + L3 identity) before dispatch." +source = ["crates/openab-gateway/src/adapters/feishu.rs#parse_message_event", "crates/openab-core/src/adapter.rs#gate_incoming"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "implemented" +note = "On core L3 DenyIdentity, gateway echoes a throttled deny message (\"You are not on this bot's trusted list. Your ID: ... Ask the admin to add it to allowed_users.\"), <=1 echo per platform:sender per window. DenyScope -> silent drop." +source = ["crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "implemented" +note = "Groups require @mention unless bot has participated in the thread (Discord-style \"involved\"). Modes via FEISHU_ALLOW_USER_MESSAGES: involved / mentions / multibot_mentions (default — require @mention if another bot is in the thread). Participation cache TTL = FEISHU_SESSION_TTL_HOURS (24 h)." +source = ["crates/openab-gateway/src/adapters/feishu.rs#parse_message_event", "crates/openab-gateway/src/adapters/feishu.rs#detect_and_mark_multibot"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "n_a" +note = "Platform has no slash-command system; /reset and /cancel arrive as plain text and are intercepted by core (not the adapter). Adapter does no slash parsing." +source = ["crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "partial" +note = "FEISHU_ALLOW_BOTS (off/mentions/all) + FEISHU_TRUSTED_BOT_IDS + per-chat max_bot_turns (default 20) loop-guard. Caveat: Feishu marks other bots as sender_type=\"user\" and doesn't push bot messages over WS, so without trusted_bot_ids peer bots can't be identified; multibot_mentions detection is done via @mention of a known bot id, not sender type." +source = ["crates/openab-gateway/src/adapters/feishu.rs#parse_message_event", "crates/openab-gateway/src/adapters/feishu.rs#detect_and_mark_multibot"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Routing keyed by chat_id; channel_type = direct (p2p) vs group; thread_id from root_id / parent_id. Dedupe by event_id + message_id; self-echo dedupe on sent message_id (Feishu pushes the bot's own messages back)." +source = ["crates/openab-gateway/src/adapters/feishu.rs#parse_message_event"] +pr = "" + +[[openab_features]] +feature = "cron_dispatch" +status = "not_implemented" +note = "Not wired for scheduled cron dispatch: `VALID_PLATFORMS` (cron.rs) covers only discord/slack/telegram and no adapter is registered for this platform in `cron_adapters`, so `cronjob.toml` jobs targeting it are rejected at startup by `validate_cronjobs`." +source = [] +pr = "" + + +# ═══ Schema 3 — platform-quirks ══════════════════════════════════════════════ + +[[quirks]] +date = "2026-07-04" +title = "20-edit-per-message cap -> streaming card promotion" +note = """ +Feishu officially documents a hard limit of 20 edits per message: PATCH /im/v1/messages/{id} for text/post returns errcode 230072 once exceeded ("一条消息最多可编辑 20 次" / "the message has reached the number of times it can be edited"). This is the central constraint shaping OpenAB streaming. The adapter (a) preemptively stops at 18 edits (FEISHU_EDIT_CAP, 2-edit safety margin for in-flight races), (b) parses the body code (JSON-code-first, substring fallback) to catch server-side 230072 even on HTTP 200, and (c) on cap, promotes to a CardKit v2 streaming card (no such cap) or, in post mode, lets core's finalize path delete the placeholder and send fresh content. Card-update uses its own 5-QPS/message frequency limit (errcode 230020) instead. (The edit-time window is separately admin-configured, errcode 230075.) +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/server-docs/im-v1/message/update" + +[[quirks]] +date = "2026-07-04" +title = "post-vs-card streaming state machine" +note = """ +FEISHU_CARD_STREAMING_MODE = auto (default) | card | post. In auto, short replies stay a native post reply (nicer thread UI); long / code-fence / table replies (which markdown_to_post degrades — tables are dropped entirely, Issue #1124) promote to a card (should_use_card, with has_code_fence / has_markdown_table). The post->card swap is invisible to core: the gateway keeps reporting the original om_post message_id back so core's edit loop is oblivious. FEISHU_CARD_FALLBACK_TO_POST=true (default) is a second safety net back to the post path on any card failure. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#should_use_card" + +[[quirks]] +date = "2026-07-04" +title = "markdown rendering is lossy" +note = """ +markdown_to_post converts to Feishu post: code fences -> code_block, [t](url) -> a, and bold/italic/strike markers are stripped (not rendered). Markdown tables are not representable in post at all — that's a primary reason the auto-promotion to CardKit exists (cards render tables/markdown natively). +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#markdown_to_post" + +[[quirks]] +date = "2026-07-04" +title = "message_id shape validation (defence-in-depth)" +note = """ +Any command that interpolates a message_id into a REST path (edit_message, delete_message, add_reaction, remove_reaction) is gated by is_valid_feishu_message_id (om_ + [A-Za-z0-9_], len 4-128), rejecting crafted ids containing / ? #. Internal card message_ids from trusted gateway session state bypass the check. The "draft" sentinel is a benign no-op skip. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#is_valid_feishu_message_id" + +[[quirks]] +date = "2026-07-04" +title = "bot identity & self/peer-bot handling" +note = """ +Bot open_id is resolved at startup and on WS reconnect via /bot/v3/info; without it, mention gating can't work. Feishu pushes the bot's own sent messages back over WS (handled by self-echo dedupe on the returned message_id) and does NOT push other bots' messages to a bot — so multibot detection relies on @mentions of known bot ids, not on sender_type (which is user for peer bots). +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#parse_message_event" + +[[quirks]] +date = "2026-07-04" +title = "webhook security posture" +note = """ +Webhook path enforces: 1 MB body cap, per-IP rate limit, SHA256 signature (only if FEISHU_ENCRYPT_KEY set — a warning is logged and verification is skipped if unset), AES-256-CBC decrypt of encrypt payloads (key = SHA256(encrypt_key), IV = first 16 bytes), constant-time verification_token check, and the URL-verification challenge handshake. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#verify_signature" + +[[quirks]] +date = "2026-07-04" +title = "20-edit hard cap officially documented (edit errcodes)" +note = """ +20-edit-per-message hard cap is officially documented (errcode 230072); edit is sender-only (230071); edit-time window is admin-configured (230075) — not a fixed 14-day/230031 limit as previously drafted. Global 1000/min / 50 QPS, per-message 5 QPS (230020). +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/server-docs/im-v1/message/update" + +[[quirks]] +date = "2026-07-04" +title = "message size & send rate limits" +note = """ +Text content <=150 KB; post/card <=30 KB; 5 QPS/user & 5 QPS/group shared, 1000/min / 50 QPS global; user opt-out -> 230053, out-of-scope -> 230013. +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/server-docs/im-v1/message/create" + +[[quirks]] +date = "2026-07-04" +title = "native threads via reply API, no reply deadline" +note = """ +Reply API supports native threads via reply_in_thread (default false) + root_id/parent_id; no reply-time deadline (the uuid is a 1-hour idempotency window only). +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/server-docs/im-v1/message/reply" + +[[quirks]] +date = "2026-07-04" +title = "inbound reaction events exist but not yet ingested" +note = """ +Bot can add reactions and remove by reaction_id; inbound reaction events exist as im.message.reaction.created_v1/deleted_v1 (subscription required) — OpenAB adapter does not yet ingest them. +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/server-docs/im-v1/message-reaction/create" + +[[quirks]] +date = "2026-07-04" +title = "no native slash commands, DM-only bot menu" +note = """ +No native slash commands; only a DM-only custom bot menu (<=3 main/<=5 sub). OpenAB treats /reset etc. as plain text, intercepted in core. +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/bot-v3/bot-customized-menu" + +[[quirks]] +date = "2026-07-04" +title = "no typing-indicator or bot->bot delivery documented" +note = """ +No public bot typing-indicator API and no documented bot->bot event delivery — Message FAQ is silent on both; OpenAB uses reactions/streaming card for progress and infers peer bots via @mention only. +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/server-docs/im-v1/faq" + +[[quirks]] +date = "2026-07-04" +title = "adapter relies on 230072 cap -> CardKit streaming-card promotion" +note = """ +Adapter relies on the 230072 cap -> CardKit streaming-card promotion; card streaming (S6) defaults to auto. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#should_use_card" + +[[quirks]] +date = "2026-07-04" +title = "create_topic is a deliberate no-op" +note = """ +create_topic (from core create_thread) is a deliberate no-op in the Feishu adapter — core falls back to same-channel reply. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs" + +[[quirks]] +date = "2026-07-04" +title = "reaction emoji support limited to 8-entry map" +note = """ +Reaction emoji support limited to an 8-entry hard-coded map; unmapped emojis are silently dropped. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#emoji_to_feishu_reaction" + +[[quirks]] +date = "2026-07-04" +title = "slash commands intercepted in core, not adapter" +note = """ +Slash commands /reset and /cancel are intercepted in core gateway.rs, not the adapter; dispatch.rs does not hold the literals. +""" +kind = "openab_decision" +source = "crates/openab-core/src/gateway.rs" diff --git a/docs/platforms/schema/googlechat.toml b/docs/platforms/schema/googlechat.toml new file mode 100644 index 000000000..4bd02224e --- /dev/null +++ b/docs/platforms/schema/googlechat.toml @@ -0,0 +1,366 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Platform capability schema — Google Chat +# ═══════════════════════════════════════════════════════════════════════════ + +schema_version = "2026-07-08" + +[platform] +name = "googlechat" +official_docs = "https://developers.google.com/workspace/chat" +description = "Google Chat (Workspace) app — webhook/Pub-Sub interactions with REST send-back, threaded/flat spaces." + + +# ═══ Schema 1 — platform-capability (source of truth: official docs) ═════════ + +[capability.transport] +kind = "webhook" +note = "HTTP endpoint URL or Pub/Sub; adapter accepts both envelope shapes. Google Chat calls the app on interaction events; app must respond within ~30 s." +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + +[capability.inbound_auth] +scheme = "jwt_rs256" +note = "Bearer ID-token (JWT, RS256) in Authorization header, signed by Google, verified against Google JWKS (https://www.googleapis.com/oauth2/v3/certs). iss=https://accounts.google.com; aud verified against the app's configured audience (project number for HTTP-endpoint apps, or app URL); email claim must equal chat@system.gserviceaccount.com." +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + +[capability.threads] +model = "native" +note = "Spaces are either threaded or flat (in-line); reply targets a thread.name/threadKey with messageReplyOption (adapter uses REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD). Thread field is ignored in un-threaded spaces." +source = "https://developers.google.com/workspace/chat/create-messages" + +[capability.slash_commands] +supported = true +note = "Registered in Cloud console (APIs & Services → Google Chat API → Configuration → Add a command; /name, commandId 1-1000). Delivered as a MESSAGE event carrying message.slashCommand / message.annotation.slashCommand with the matching commandId." +source = "https://developers.google.com/workspace/chat/commands" + +[capability.mentions] +method = "at_mention" +note = "In spaces the app is only invoked when @mentioned (or via slash command / app-added); mention text is stripped into argumentText. In DMs every message reaches the app. Mentions encoded as ( for @all)." +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + +[capability.emoji_reactions] +bot_can_add = false +bot_can_remove = false +bot_receives_events = true +note = "Reaction resource + create/delete/list methods exist, BUT reactions.create/reactions.delete require USER authentication (REST reference lists only chat.messages.reactions.create / chat.messages.reactions / chat.messages scopes; no app-auth alternative). A chat.bot app therefore cannot add or remove reactions. Apps can still receive REACTION_ADDED/REACTION_REMOVED interaction events." +source = "https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages.reactions/create" + +[capability.edit_message] +supported = true +note = "spaces.messages.patch with updateMask=text; with app auth an app can update only messages it created. Scope chat.bot." +source = "https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/patch" + +[capability.delete_message] +supported = true +scope = "own" +note = "spaces.messages.delete; with app auth an app can delete only messages it created (not user/other-app messages). Scope chat.bot." +source = "https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/delete" + +[capability.rich_content] +markdown = true +cards = true +buttons = true +note = "Native text formatting (*bold*, _italic_, ~strike~, `code`, ```block```, links, mentions, bullet/quote). Cards v2 + interactive widgets/buttons/dialogs (app auth)." +source = "https://developers.google.com/workspace/chat/format-messages" + +[capability.attachments] +inbound = ["image", "audio", "file"] +outbound = ["image", "audio", "video", "file"] +max_size_mb = 200 +note = "Inbound: user-uploaded attachments downloaded via Media API (UPLOADED_CONTENT; Drive-sourced needs the Drive API, not handled). Upload limit 200 MB; some file types are blocked. Outbound app uploads also up to 200 MB (a message with an attachment can't also carry accessory widgets)." +source = "https://developers.google.com/workspace/chat/upload-media-attachments" + +[capability.message_length_limit] +max_chars = 32000 +note = "Max message payload (text + cards) is 32,000 bytes; larger must be split into multiple messages. Cap is in bytes." +source = "https://developers.google.com/workspace/chat/create-messages" + +[capability.dm_support] +supported = true +note = "1:1 DM space between a user and the app (space type DM). App receives all DM messages (no mention required)." +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + +[capability.group_model] +kinds = ["dm", "group_chat", "space"] +note = "\"Spaces\": DM (1:1), group chat (unnamed multi-person), and named spaces/rooms. Spaces are threaded or flat. Routing keyed on space.name (e.g. spaces/AAAA)." +source = "https://developers.google.com/workspace/chat/create-messages" + +[capability.group_sender_identity] +stable_id = "yes" +note = "Stable & non-consent-gated — every message event carries sender = user resource name users/{id} + displayName + type (HUMAN/BOT)." +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + +[capability.send_model] +model = "push_only" +reply_token_ttl_sec = 0 +max_objects_per_send = 0 +note = "Push (REST spaces.messages.create) with app auth; no reply-token/TTL window. Interaction responses may also be returned synchronously in the webhook response body." +source = "https://developers.google.com/workspace/chat/create-messages" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "App can post proactively to spaces it belongs to. Quota: 3,000 message writes/min per project (create+patch+delete combined), and 1 write/sec per space (shared across all apps in the space; 10/sec only during data import). 429 Too many requests on overflow → truncated exponential backoff." +source = "https://developers.google.com/workspace/chat/limits" + +[capability.bot_to_bot] +delivered = false +note = "Not delivered — the MESSAGE interaction event is defined as \"A user messages a Chat app\"; other apps'/bots' messages are not surfaced to a Chat app. (No doc positively states delivery; treat as unsupported. Adapter also drops sender.type == BOT inbound.)" +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + +[capability.typing_indicator] +supported = false +note = "Not supported — Google Chat exposes no typing/composing API for apps; none of the documented app-facing interaction events or send surfaces include a typing signal. (Verified negative against the interactions surface.)" +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + + +# ═══ Schema 2 — openab-feature-support (source of truth: our code + PR) ══════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "POST {space}/messages with markdown→gchat conversion; returns message name." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#send_message"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "implemented" +note = "Splits at GOOGLE_CHAT_MESSAGE_LIMIT = 4096 (conservative vs the 32 KB API cap) on newline/space boundaries, UTF-8-safe; each chunk a separate message; first message id / first error acked." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#split_text"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "partial" +note = "No native streaming API. Gateway adapter leaves uses_native_streaming=false, so core streams via the post-then-edit_message loop; each edit sends the full accumulated text as a patch call." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#edit_message", "crates/openab-core/src/adapter.rs#uses_native_streaming"] +pr = "" + +[[openab_features]] +feature = "reply_quote" +status = "partial" +note = "No dedicated quote/reply-to. send_message_with_reply uses the trait default → plain send_message; thread continuity is via thread_id, not a per-message quote." +source = ["crates/openab-core/src/adapter.rs#send_message_with_reply", "crates/openab-gateway/src/adapters/googlechat.rs#send_message"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "implemented" +note = "PATCH {message_name}?updateMask=text; core gates the streaming edit rate on the ack. Own messages only (platform rule)." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#edit_message", "crates/openab-core/src/gateway.rs#edit_message"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "not_implemented" +note = "GatewayAdapter overrides delete_message to emit a fire-and-forget command:\"delete_message\" instead of the trait default (edit-to-zero-width). The googlechat adapter does not match delete_message in handle_reply (only add_reaction/remove_reaction/create_topic/edit_message), so it falls through to the send path with empty text → hits the empty-message short-circuit and sends nothing. Net: delete is a no-op on Google Chat." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#handle_reply", "crates/openab-core/src/gateway.rs#delete_message", "crates/openab-core/src/adapter.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "not_implemented" +note = "Core dispatches add_reaction/remove_reaction commands but the adapter early-returns on them (match … => return). Deliberate: Google Chat reactions require USER auth; the app (chat.bot) cannot react. Status/thinking reactions therefore never render on Google Chat." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#handle_reply", "crates/openab-core/src/gateway.rs#add_reaction"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "partial" +note = "Inbound thread.name is captured and used to keep replies in-thread (send sets thread.name + messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD). create_thread→create_topic command is early-returned by the adapter, so explicit topic creation is a no-op and core falls back to the same channel." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#handle_reply", "crates/openab-core/src/gateway.rs#create_thread"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "implemented" +note = "Async download via Media API after the 200 response: images (resize longest side ≤1200px, JPEG q75, ≤10 MB; GIF passthrough); text-like files (extension whitelist, ≤512 KB each, ≤5 files / ≤1 MB aggregate); audio (≤25 MB, stored raw). Drive-sourced & video skipped." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#parse_attachments", "crates/openab-gateway/src/adapters/googlechat.rs#download_googlechat_image"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "partial" +note = "Adapter downloads audio and emits it as an audio attachment with real MIME; STT happens in core only if stt_config.enabled, else forwarded as a \"transcription failed\" note." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#download_googlechat_audio", "crates/openab-core/src/gateway.rs#process_gateway_event"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "implemented" +note = "Two layers: (1) webhook JWT verify — RS256 via Google JWKS + email==chat@system.gserviceaccount.com; (2) shared ingress router.gate_incoming L2 scope + L3 identity in process_gateway_event." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#verify_email_claim", "crates/openab-core/src/gateway.rs#process_gateway_event"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "implemented" +note = "DenyIdentity → throttled request-access echo (\"Your ID: …\") via adapter.send_message; DenyScope → silent drop. Platform-agnostic; delivered through the normal Google Chat send path." +source = ["crates/openab-core/src/gateway.rs#process_gateway_event"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "implemented" +note = "Platform-native. Google Chat itself only delivers space messages when the app is @mentioned. Core should_skip_event mention-gating only fires for channel_type == group/supergroup; Google Chat spaces surface as ROOM/SPACE/DM, so the core @mention string check never triggers — gating is enforced by the platform, not core." +source = ["crates/openab-core/src/gateway.rs#should_skip_event", "crates/openab-gateway/src/adapters/googlechat.rs#send_googlechat_event"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "implemented" +note = "Core-side. /reset, /cancel, /model(s)/agents intercepted in process_gateway_event before dispatch; responses sent via adapter.send_message. Not wired to Google Chat's native slash-command registration — plain-text commands." +source = ["crates/openab-core/src/gateway.rs#process_gateway_event"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "partial" +note = "Bot senders are dropped inbound (sender.user_type == BOT → skip); core's multibot/other-bot streaming suppression can't observe other bots since Chat doesn't deliver their messages. use_streaming ignores other_bot_present for gateway adapters." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#send_googlechat_event", "crates/openab-core/src/gateway.rs#use_streaming"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Session/route keyed on space.name + optional thread_id; ChannelInfo carries id=space, channel_type=space type, thread_id." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#send_googlechat_event"] +pr = "" + +[[openab_features]] +feature = "cron_dispatch" +status = "not_implemented" +note = "Not wired for scheduled cron dispatch: `VALID_PLATFORMS` (cron.rs) covers only discord/slack/telegram and no adapter is registered for this platform in `cron_adapters`, so `cronjob.toml` jobs targeting it are rejected at startup by `validate_cronjobs`." +source = [] +pr = "" + + +# ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ + +[[quirks]] +date = "2026-07-04" +title = "Auth asymmetry: two independent credential paths" +note = "Inbound webhooks are authenticated by verifying Google's ID-token (email==chat@system.gserviceaccount.com, RS256 via JWKS, iss=accounts.google.com, audience-checked). Outbound API calls use a *separate* service-account → OAuth2 JWT-bearer exchange (scope=https://www.googleapis.com/auth/chat.bot), cached with a 300 s refresh margin. Missing outbound creds degrade to a logged dry-run that still acks failure to core." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#build_jwt" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Reactions are structurally impossible for the bot" +note = "The single biggest divergence from other adapters: Google Chat's reaction API is user-auth only (the reactions.create REST reference explicitly lists only user-auth scopes). A chat.bot app cannot add/remove reactions at all, so OpenAB's status-reaction UX (👀 queued / 🤔 thinking / tool emojis / mood face) is silently dropped. The adapter deliberately early-returns on add_reaction/remove_reaction/create_topic rather than issuing doomed API calls. Consider a message-edit-based status line if a progress indicator is needed here." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages.reactions/create" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Markdown must be raw, once" +note = "markdown_to_gchat converts CommonMark → Chat syntax (**b**→*b*, *i*→_i_, ~~s~~→~s~, [t](u)→, headings→bold, code fences/inline code passthrough). It is applied by *both* send_message and edit_message; passing already-converted text double-converts (e.g. *bold* re-parsed as *italic*). Core must always emit raw markdown on streaming edits." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#markdown_to_gchat" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "30-second webhook deadline vs. attachment downloads" +note = "Attachment-bearing messages spawn a background (panic-guarded) task and return {} immediately so the webhook meets Chat's ~30 s deadline; the GatewayEvent is emitted only after downloads finish. Text-only messages emit synchronously. Event dropped if both text and all attachments are empty/failed." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#webhook" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Two webhook envelope shapes" +note = "Google Chat delivers either top-level (message/user/space) for HTTP-endpoint mode or wrapped under chat.messagePayload for Pub/Sub mode. The handler prefers the wrapped form and falls back to top-level." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#webhook" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Chunk cap set below the API cap" +note = "Adapter chunks at 4096 chars while the API accepts 32,000 bytes — a conservative choice (matches the legacy client-visible limit) that produces more messages than strictly necessary for very long replies." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#GOOGLE_CHAT_MESSAGE_LIMIT" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Delete is a silent no-op (not even the edit fallback)" +note = "Unlike platforms where delete_message falls back to the trait's edit-to-zero-width, on Google Chat the delete_message command isn't matched in handle_reply, falls through to the send path with empty text, and hits the empty-message short-circuit — so nothing is sent and no edit occurs. Streaming-placeholder cleanup that relies on delete is therefore a no-op here." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#handle_reply" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Reaction API requires user auth (findings log)" +note = "reactions.create/reactions.delete require user auth (REST ref lists only chat.messages.reactions* / chat.messages, no app-auth); a chat.bot app cannot add/remove reactions → OpenAB status-reaction UX is a no-op on this platform." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages.reactions/create" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Max message payload is 32,000 bytes" +note = "Max message payload (text+cards) is 32,000 bytes; longer content must be split into multiple messages (adapter chunks conservatively at 4096 chars)." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/create-messages" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Edit/delete operate on own messages only" +note = "With app auth, messages.patch (edit) and messages.delete operate on only the app's own messages; edit updateMask supports text." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/patch" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Attachment upload limit is 200 MB" +note = "Attachment upload limit is 200 MB; some file types are blocked (an attachment message can't also carry accessory widgets). OpenAB downloads far below this (image 10 MB / file 512 KB / audio 25 MB) and skips Drive-sourced & video attachments." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/upload-media-attachments" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Rate limits: 3,000/min project, 1/sec space" +note = "Rate limits: 3,000 message writes/min per project and 1 write/sec per space (shared across apps; 10/sec only during data import); 429 → truncated exponential backoff." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/limits" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Slash commands registered with commandId 1-1000" +note = "Slash commands are registered in the Cloud console with a commandId 1-1000 and delivered as a MESSAGE event carrying message.slashCommand / message.annotation.slashCommand." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/commands" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Mention-gating is enforced platform-side" +note = "In spaces the app is only invoked when @mentioned (mention stripped into argumentText); DMs deliver every message — so mention-gating is enforced platform-side, not by OpenAB's @mention string check." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "No bot-to-bot delivery and no typing API" +note = "The MESSAGE interaction event is defined as \"A user messages a Chat app\"; other bots'/apps' messages are not delivered, and there is no typing/composing API for apps." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Inbound webhooks signed by chat@system.gserviceaccount.com" +note = "Inbound webhooks are signed by chat@system.gserviceaccount.com (RS256, JWKS at oauth2/v3/certs); OpenAB verifies issuer (accounts.google.com), audience, exp, and the email claim." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" +refs = [] diff --git a/docs/platforms/schema/line.toml b/docs/platforms/schema/line.toml new file mode 100644 index 000000000..d88c387af --- /dev/null +++ b/docs/platforms/schema/line.toml @@ -0,0 +1,391 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# LINE — machine-readable platform schema +# Converted from the adapter source + official docs. Structure follows _template.toml. +# ═══════════════════════════════════════════════════════════════════════════ + +schema_version = "2026-07-08" + +[platform] +name = "line" +official_docs = "https://developers.line.biz/en/docs/messaging-api/" +description = "LINE Messaging API — bots run as LINE Official Accounts, receiving user events via webhook and sending via Reply/Push APIs." + + +# ═══ Schema 1 — platform-capability ══════════════════════════════════════════ + +[capability.transport] +kind = "webhook" +note = "HTTPS POST to the bot's registered endpoint; events arrive as a batched events[] payload." +source = "https://developers.line.biz/en/docs/messaging-api/receiving-messages/" + +[capability.inbound_auth] +scheme = "hmac_sha256" +note = "HMAC-SHA256 over the raw request body, keyed by the channel secret, Base64-encoded, compared to the x-line-signature header." +source = "https://developers.line.biz/en/reference/messaging-api/#signature-validation" + +[capability.threads] +model = "none" +note = "No native threads/topics. LINE has flat 1:1 chats, group chats and multi-person rooms; there is no thread or topic primitive." +source = "https://developers.line.biz/en/reference/messaging-api/#source-user" + +[capability.slash_commands] +supported = false +note = "Not a platform feature — no command registration/delivery API. Any /cmd is just plain message text." +source = "https://developers.line.biz/en/reference/messaging-api/#message-event" + +[capability.mentions] +method = "self_flag" +note = "mention.mentionees[] on text message events; each mentionee carries an optional userId and an isSelf flag that is true for the bot itself (no username matching needed)." +source = "https://developers.line.biz/en/reference/messaging-api/#wh-text" + +[capability.emoji_reactions] +bot_can_add = false +bot_can_remove = false +bot_receives_events = false +note = "Bot cannot add/remove reactions (no API). Bot cannot receive them either: the documented webhook event list (message, unsend, follow/unfollow, join/leave, member join/leave, postback, video-viewing-complete, beacon, account-link, membership) contains no reaction event — verified against the current Messaging API reference, which does not surface a reaction webhook to bots." +source = "https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects" + +[capability.edit_message] +supported = false +note = "The API has no endpoint to edit an already-sent message." +source = "https://developers.line.biz/en/reference/messaging-api/" + +[capability.delete_message] +supported = false +scope = "none" +note = "No bot-initiated delete. Only the user can unsend, which delivers an unsend webhook to the bot; the bot cannot delete its own or others' messages." +source = "https://developers.line.biz/en/reference/messaging-api/#unsend-event" + +[capability.rich_content] +markdown = false +cards = true +buttons = true +note = "Rich messages: stickers, images, imagemap, buttons/confirm/carousel templates, and Flex Messages (JSON-defined layouts). No Markdown; text is plain (LINE emoji via product/emoji IDs)." +source = "https://developers.line.biz/en/docs/messaging-api/message-types/" + +[capability.attachments] +inbound = ["image", "audio", "video", "file"] +outbound = ["image", "audio", "video", "file"] +outbound_delivery = "url" +note = "Inbound via get-content by message ID (/v2/bot/message/{id}/content on the api-data.line.me host): images, video, audio, files. contentProvider.type is \"line\" (fetchable) or \"external\" (URL only, not fetchable via get-content). User-sent content auto-expires, so fetch promptly. Outbound media is sent by URL, not upload." +source = "https://developers.line.biz/en/reference/messaging-api/#get-content" + +[capability.message_length_limit] +max_chars = 5000 +note = "5000 characters per text message object, counted in UTF-16 code units; chunking required above this." +source = "https://developers.line.biz/en/reference/messaging-api/#text-message" + +[capability.dm_support] +supported = true +note = "1:1 chat between a user and the LINE Official Account." +source = "https://developers.line.biz/en/reference/messaging-api/#source-user" + +[capability.group_model] +kinds = ["group", "room", "user"] +note = "Two multi-user taxonomies: group (groupId) and room / multi-person chat (roomId), plus 1:1 user chats. No channels/spaces." +source = "https://developers.line.biz/en/docs/messaging-api/group-chats/" + +[capability.group_sender_identity] +stable_id = "consent_gated" +note = "Consent-gated and unreliable: userId is optional in group/room source objects and is only present for users on LINE for iOS/Android; it can be absent otherwise." +source = "https://developers.line.biz/en/reference/messaging-api/#source-group" + +[capability.send_model] +model = "hybrid" +reply_token_ttl_sec = 60 +max_objects_per_send = 5 +note = "Reply API (/v2/bot/message/reply) consumes a one-time replyToken from the inbound webhook and is free; Push API (/v2/bot/message/push) targets a user/group/room ID at any time and counts against quota. Reply token must be used within ~1 minute; LINE explicitly says the limit may change without notice and use beyond one minute isn't guaranteed — don't rely on it. Both endpoints accept up to 5 message objects per request." +source = "https://developers.line.biz/en/reference/messaging-api/#send-reply-message" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Via Push API, but metered: each plan has a monthly free-message quota (amount depends on subscription plan/region), with paid overage. A get-quota/consumption API exists. Up to 5 message objects per request." +source = "https://developers.line.biz/en/docs/messaging-api/pricing/" + +[capability.bot_to_bot] +delivered = false +note = "LINE Official Accounts (bots) do not receive messages from other bots; webhook message events are for user-originated content." +source = "https://developers.line.biz/en/docs/messaging-api/overview/" + +[capability.typing_indicator] +supported = true +note = "\"Display a loading animation\" endpoint, 1:1 chats only (\"You can't specify group chats or multi-person chats\"), rate-limited to 100 req/s. It is a loading animation while the user is viewing the chat, not a per-keystroke typing indicator." +source = "https://developers.line.biz/en/docs/messaging-api/use-loading-indicator/" + + +# ═══ Schema 2 — openab-feature-support ═══════════════════════════════════════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "Outbound text via hybrid dispatch: tries Reply API first (free), falls back to Push API. Only {\"type\":\"text\"} objects are sent (no rich content)." +source = ["crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply"] +pr = "#1291" + +[[openab_features]] +feature = "message_split" +status = "partial" +note = "Router-level split_delivery + per-adapter message_limit handle length bounds generically; the LINE dispatcher itself sends a single text object per reply and does not re-chunk. LINE's own cap is 5000 chars/object." +source = ["crates/openab-core/src/adapter.rs#split_delivery", "crates/openab-core/src/adapter.rs#message_limit", "crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "not_implemented" +note = "No native streaming; adapter does not override uses_native_streaming (trait default false) and LINE has no edit API to drive post+edit streaming. Effectively send-once/batched." +source = ["crates/openab-core/src/adapter.rs#uses_native_streaming", "crates/openab-core/src/adapter.rs#edit_message"] +pr = "" + +[[openab_features]] +feature = "reply_quote" +status = "workaround" +note = "LINE \"reply\" is a delivery mechanism (reply-token), not a UI quote. dispatch_line_reply uses the token to answer in-context; there is no message-quote rendering. Reply vs Push chosen by token freshness." +source = ["crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply"] +pr = "#1291" + +[[openab_features]] +feature = "edit_message" +status = "n_a" +note = "LINE has no edit endpoint. Trait default edit_message returns \"edit_message not supported\"; adapter does not override." +source = ["crates/openab-core/src/adapter.rs#edit_message"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "n_a" +note = "No LINE delete endpoint. Trait default delete_message falls back to editing to a zero-width space — which also fails on LINE since edit is unsupported." +source = ["crates/openab-core/src/adapter.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "n_a" +note = "LINE exposes no add/remove-reaction API. The gateway dispatcher explicitly ignores add_reaction/remove_reaction commands (logs \"ignoring unsupported command\", returns false)." +source = ["crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply"] +pr = "#1291" + +[[openab_features]] +feature = "threads_topics" +status = "n_a" +note = "LINE has no thread primitive. The create_topic command is explicitly ignored by the dispatcher alongside the reaction commands." +source = ["crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply", "crates/openab-core/src/adapter.rs#create_thread"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "partial" +note = "Images and audio are downloaded via get-content (/v2/bot/message/{id}/content), size-guarded (Content-Length pre-check + streaming cap), and stored (images are resized/compressed; audio stored as-is). external-provider content and missing access-token produce a status-only attachment (not dropped); video/files are not ingested (event filter passes only text/image/audio)." +source = ["crates/openab-gateway/src/adapters/line.rs"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "not_implemented" +note = "Audio is downloaded and stored as an attachment only; no speech-to-text is performed in the LINE path." +source = ["crates/openab-gateway/src/adapters/line.rs"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "implemented" +note = "L1 signature at ingress (HMAC-SHA256 over raw body vs x-line-signature); shared L2 scope / L3 identity trust gate applies in the gateway ingress path, keyed by platform." +source = ["crates/openab-gateway/src/adapters/line.rs", "crates/openab-core/src/gateway.rs#process_gateway_event"] +pr = "#1291" + +[[openab_features]] +feature = "deny_echo" +status = "workaround" +note = "On L3 identity-deny the gateway echoes the sender their ID via adapter.send_message (throttled per platform:sender). On LINE that echo flows through the same hybrid dispatch_line_reply keyed by the original event's reply token, so it is Reply in practice (deny happens at ingress while the token is still fresh); note this is not a hard \"never Push\" guarantee — if the token were expired the shared dispatcher would still fall back to Push. 1:1 echo includes the UID; group/room echo carries no stable ID." +source = ["crates/openab-core/src/gateway.rs#process_gateway_event", "crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply"] +pr = "#1291" + +[[openab_features]] +feature = "mention_gating" +status = "implemented" +note = "In group/room events the adapter drops the message unless a mentionee has isSelf=true (the bot). 1:1 DMs always pass. No env var / bot-name matching needed — LINE flags self-mention." +source = ["crates/openab-gateway/src/adapters/line.rs"] +pr = "#1291" + +[[openab_features]] +feature = "slash_commands" +status = "n_a" +note = "LINE has no slash-command surface; commands would arrive as plain text. /reset, /cancel handling is not wired in the LINE path (events are filtered to text/image/audio and forwarded as-is)." +source = ["crates/openab-gateway/src/adapters/line.rs"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "n_a" +note = "LINE does not deliver other bots' messages, and inbound is_bot is hard-coded false, so multi-bot coordination cannot trigger on LINE." +source = ["crates/openab-gateway/src/adapters/line.rs"] +pr = "#1291" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Channel keyed by groupId (group) / roomId (room) / userId (1:1); a group/room missing its ID is skipped. Sender falls back to \"unknown\" when userId is absent." +source = ["crates/openab-gateway/src/adapters/line.rs"] +pr = "#1291" + +[[openab_features]] +feature = "cron_dispatch" +status = "not_implemented" +note = "Not wired for scheduled cron dispatch: `VALID_PLATFORMS` (cron.rs) covers only discord/slack/telegram and no adapter is registered for this platform in `cron_adapters`, so `cronjob.toml` jobs targeting it are rejected at startup by `validate_cronjobs`." +source = [] +pr = "" + + +# ═══ Schema 3 — platform-quirks ══════════════════════════════════════════════ + +[[quirks]] +date = "2026-07-04" +title = "Reply/Push hybrid dispatch (the core LINE model)" +note = """ +LINE splits outbound sending into two APIs with different economics. Every inbound webhook carries a one-time replyToken; using it (/message/reply) is free but the token is short-lived (~1 minute, officially "may change without notice" and not guaranteed beyond one minute). Push (/message/push) works anytime but consumes the monthly quota. OpenAB caches the token at webhook receipt time (TTL tracked from true receipt, REPLY_TOKEN_TTL_SECS = 50 in crates/openab-gateway/src/lib.rs, deliberately under LINE's ~60s), tries Reply first, and only falls back to Push when the token is missing/expired. The cache is bounded (REPLY_TOKEN_CACHE_MAX = 10_000) and swept periodically. + +Duplicate-safety bias: on a Reply API error that is not a clearly-unusable-token 400 (e.g. network error, or a non-token 4xx/5xx), the dispatcher does not fall back to Push — it assumes the reply may have landed and returns used_reply=true to avoid double-sending. Only an explicit "invalid … reply token" or "expired" 400 triggers Push fallback. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/lib.rs#REPLY_TOKEN_TTL_SECS" +refs = ["#1291"] + +[[quirks]] +date = "2026-07-04" +title = "Sender identity is best-effort, \"unknown\" is never trusted" +note = """ +userId is optional in group/room sources (present only for LINE iOS/Android users). When absent, the sender collapses to the literal "unknown". Decision: "unknown" is never allowlistable — it cannot be added to allowed_users, because it is not a stable identity. Group admission for LINE is instead gated by self-mention. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/line.rs" +refs = ["#1291"] + +[[quirks]] +date = "2026-07-04" +title = "@mention gating in groups/rooms" +note = """ +In group/room events the adapter forwards the message only if some mentionee has isSelf=true — i.e. the bot itself was @-mentioned. 1:1 DMs always pass. This relies entirely on LINE's isSelf flag, so no bot-name string matching or env var is needed. A group/room text with no mention object, or one where only other users are mentioned, is dropped. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/line.rs" +refs = ["#1291"] + +[[quirks]] +date = "2026-07-04" +title = "Early-ack webhook processing" +note = """ +The webhook handler validates the signature, returns 200 OK, then spawns background processing so slow image/audio downloads don't cause LINE to redeliver. Tradeoff (documented in-code): once acked, a later crash is not retried by LINE, and cross-payload ordering can invert if an image event is slower than a following text event. A shared semaphore (line_webhook_semaphore, LINE_WEBHOOK_CONCURRENCY_MAX) bounds concurrent post-ack work to cap backlog under bursts; a saturated semaphore makes new webhooks wait before spawning. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/line.rs" +refs = ["#1291"] + +[[quirks]] +date = "2026-07-04" +title = "is_bot always false" +note = """ +Inbound events hard-code is_bot=false because LINE never delivers other bots' messages to a bot. This means the shared multibot machinery is inert on LINE by construction. +""" +kind = "intrinsic" +source = "crates/openab-gateway/src/adapters/line.rs" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "External-content and unsupported media are surfaced, not silently dropped" +note = """ +When an image/audio uses contentProvider.type == "external", or the access token is unconfigured, the adapter emits an attachment with a status string (e.g. "unsupported format: external content not supported", "configuration error: service not configured") rather than dropping the event — so the agent still sees that media was present. Video and generic files are filtered out earlier (only text/image/audio pass the type filter). +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/line.rs" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "No reaction webhook event; edit/delete endpoints absent" +note = """ +No reaction webhook event in the documented Messaging API event list — bots can neither add/remove nor receive reactions; edit/delete endpoints also absent. Confirms reactions/edit/delete are n/a in OpenAB. +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Reply token ~1-minute window; 5 objects per request" +note = """ +Reply token must be used within ~1 minute; LINE warns the limit may change without notice and use beyond one minute isn't guaranteed — motivates the conservative 50s TTL. Both Reply and Push accept up to 5 message objects per request. +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/docs/messaging-api/sending-messages/" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Text message objects cap at 5000 chars (UTF-16)" +note = """ +LINE text message objects cap at 5000 chars (counted in UTF-16 code units). +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/reference/messaging-api/#text-message" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Loading-animation (typing) indicator is 1:1-only" +note = """ +Loading-animation ("typing") indicator is 1:1-only ("You can't specify group chats or multi-person chats"), rate-limited 100 req/s. +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/docs/messaging-api/use-loading-indicator/" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "userId optional in group/room sources" +note = """ +userId is optional in group/room sources and only present for LINE iOS/Android users — confirms "unknown" sender is intrinsic, not a bug. +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/reference/messaging-api/#source-group" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Inbound media fetched via get-content by message ID" +note = """ +Inbound media is fetched via get-content by message ID on the api-data.line.me host; contentProvider.type is line (fetchable) or external (URL only); user content auto-expires — fetch promptly. +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/reference/messaging-api/#get-content" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Push metered by plan-dependent monthly free quota; Reply free" +note = """ +Push is metered by a plan-dependent monthly free-message quota; Reply API is free — the economic basis for the hybrid model. Open item (unknowable without running the platform): the exact monthly Push free-message quota is plan/region-dependent and not a fixed documented constant — its numeric value must be read from the account's plan / the get-quota API at runtime rather than stated here. +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/docs/messaging-api/pricing/" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "deny-echo reuses hybrid dispatch (Reply in practice)" +note = """ +deny-echo on LINE reuses the hybrid dispatch_line_reply keyed by the original event token: Reply in practice (fresh token at ingress), but not a hard "never Push" guarantee — an expired token would fall back to Push like any reply. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply" +refs = ["#1291"] + +[[quirks]] +date = "2026-07-04" +title = "LINE adapter design summary" +note = """ +LINE adapter design: L1 HMAC-SHA256 at ingress, group two-mode admission via isSelf mention-gating, "unknown" never allowlistable, is_bot always false, early-ack + semaphore-bounded background processing, single text object per reply. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/line.rs" +refs = ["#1291"] diff --git a/docs/platforms/schema/slack.toml b/docs/platforms/schema/slack.toml new file mode 100644 index 000000000..8e34c46eb --- /dev/null +++ b/docs/platforms/schema/slack.toml @@ -0,0 +1,335 @@ +# Slack — machine-readable platform schema +# Converted from the adapter source + official docs. See _template.toml for the schema. + +schema_version = "2026-07-08" + +[platform] +name = "slack" +official_docs = "https://docs.slack.dev/" +description = "Slack workspace messaging platform; OpenAB uses a native Socket Mode adapter." + + +# ═══ Schema 1 — platform-capability ══════════════════════════════════════════ + +[capability.transport] +kind = "socket_mode" +note = "Persistent WebSocket obtained from apps.connections.open; no public URL." +source = "https://docs.slack.dev/apis/events-api/using-socket-mode" + +[capability.inbound_auth] +scheme = "none" +note = "None per-event. The WebSocket is pre-authenticated by the app-level token (xapp-) sent in the Authorization header to open it; inbound events need no HMAC/signature validation (explicitly unlike the HTTP Events API, where each event must be validated)." +source = "https://docs.slack.dev/apis/events-api/using-socket-mode" + +[capability.threads] +model = "native" +note = "A thread is implicit: any message posted with thread_ts = a parent message's ts becomes a reply in that thread. reply_broadcast=true optionally also surfaces the reply to the whole channel (default false = thread-only)." +source = "https://docs.slack.dev/reference/methods/chat.postMessage" + +[capability.slash_commands] +supported = true +note = "Supported but HTTP-only — registered in app config with a Request URL; delivered via HTTP POST, not Socket Mode events. Developer slash commands cannot be invoked in message threads (only built-ins like /remind can)." +source = "https://docs.slack.dev/interactivity/implementing-slash-commands" + +[capability.mentions] +method = "at_mention" +note = "@bot renders in event text as <@BOT_UID> (or labelled <@BOT_UID|handle>); a dedicated app_mention event also fires. DMs are an implicit mention (no app_mention)." +source = "https://docs.slack.dev/reference/events/message/" + +[capability.emoji_reactions] +bot_can_add = true +bot_can_remove = true +bot_receives_events = true +note = "Bot can add (reactions.add) and remove (reactions.remove) with reactions:write; receives reaction_added/reaction_removed events. Per-item caps on distinct emoji and per-person reactions." +source = "https://docs.slack.dev/reference/methods/reactions.add" + +[capability.edit_message] +supported = true +note = "chat.update, but only messages the bot itself authored (cant_update_message otherwise). Tier-3 rate limit; edit_window_closed if the workspace's message-edit settings forbid the edit." +source = "https://docs.slack.dev/reference/methods/chat.update" + +[capability.delete_message] +supported = true +scope = "own" +note = "chat.delete with chat:write — with a bot token, deletes only messages posted by that bot; cannot delete users'/other bots' messages (no impersonation accommodation)." +source = "https://docs.slack.dev/reference/methods/chat.delete" + +[capability.rich_content] +markdown = true +cards = true +buttons = true +note = "Block Kit (sections, buttons, cards, and a markdown block) + legacy mrkdwn. A Block Kit markdown block renders real headings/lists/tables/code fences." +source = "https://docs.slack.dev/reference/methods/chat.postMessage" + +[capability.attachments] +inbound = ["image", "audio", "video", "file"] +outbound = ["image", "audio", "video", "file"] +max_size_mb = 1024 +note = "Inbound: any file the bot can see, downloaded with the bot token (files:read). Outbound uploads capped at 1 GB per file (hard limit on all plans); free workspaces additionally cap total storage at 5 GB. Note: files.upload is deprecated — sunset 2025-11-12, replaced by files.getUploadURLExternal + files.completeUploadExternal." +source = "https://docs.slack.dev/changelog/2019-03-wild-west-for-files-no-more/" + +[capability.message_length_limit] +max_chars = 12000 +note = "text: recommended <= 4,000 chars, hard-truncated by Slack at 40,000. markdown_text (and the Block Kit markdown block the adapter uses): up to 12,000 chars." +source = "https://docs.slack.dev/reference/methods/chat.postMessage" + +[capability.dm_support] +supported = true +note = "1:1 IM channels (channel IDs start with D)." +source = "https://docs.slack.dev/reference/events/message/" + +[capability.group_model] +kinds = ["channel_public", "channel_private", "im", "mpim", "thread"] +note = "workspace → channels (public C… / private G…), plus IM (D…) and multi-party IM (mpim); threads inside channels." +source = "https://docs.slack.dev/reference/events/message/" + +[capability.group_sender_identity] +stable_id = "yes" +note = "Stable per-user user (U…) ID on every message event; resolvable to display/real name via users.info. Bot senders carry bot_id (B…) instead. Not consent-gated within an authorized workspace." +source = "https://docs.slack.dev/reference/events/message/" + +[capability.send_model] +model = "push_only" +note = "Push — no reply-window/token TTL. Any chat.postMessage targeting a channel the bot is in; threading is thread_ts, not a time-bounded reply token." +source = "https://docs.slack.dev/reference/methods/chat.postMessage" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Bot may post unsolicited to channels it's a member of. Rate: no more than 1 message/second/channel (short bursts tolerated). chat.postMessage is a Special Tier method carrying both this per-channel limit and a workspace-wide limit." +source = "https://docs.slack.dev/apis/web-api/rate-limits/" + +[capability.bot_to_bot] +delivered = true +note = "Other bots' messages are delivered as message events with subtype: bot_message and a bot_id/bot_profile; the receiving app must opt to process them." +source = "https://docs.slack.dev/reference/events/message/bot_message/" + +[capability.typing_indicator] +supported = false +note = "Not used as a typing dot. Assistant mode instead surfaces an ephemeral status line via assistant.threads.setStatus (Thinking…)." +source = "https://docs.slack.dev/reference/methods/assistant.threads.setStatus" + + +# ═══ Schema 2 — openab-feature-support ═══════════════════════════════════════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "chat.postMessage with Block Kit markdown blocks + text/mrkdwn fallback. Graceful degrade to text-only on invalid_blocks/msg_blocks_too_long." +source = ["crates/openab-core/src/slack.rs#send_message"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "implemented" +note = "message_limit() = 11,900 (Block Kit markdown cap 12k minus ~100 headroom, MARKDOWN_BLOCK_LIMIT); router splits at that bound and each chunk is one markdown block via format::split_message. Known gap: split_message isn't table-aware, so a single table > 11,900 splits mid-table into raw pipes (continuation blocks lack the header/separator rows)." +source = ["crates/openab-core/src/slack.rs#message_limit", "crates/openab-core/src/slack.rs#MARKDOWN_BLOCK_LIMIT"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "implemented" +note = "Native streaming via chat.startStream/appendStream/stopStream when streaming && assistant_mode && !other_bot_present (uses_native_streaming); otherwise degrades to a post+edit placeholder; streaming=false = send-once. stopStream closes content-free (append semantics would duplicate — #1055), then chat.update writes the clean finalized Block Kit copy." +source = ["crates/openab-core/src/slack.rs#uses_native_streaming"] +pr = "#1055" + +[[openab_features]] +feature = "reply_quote" +status = "partial" +note = "No send_message_with_reply override → uses the trait default (adapter.rs) = plain send_message (reply_to ignored). Slack threading is instead expressed via thread_ts on the channel ref." +source = ["crates/openab-core/src/adapter.rs#send_message_with_reply", "crates/openab-core/src/slack.rs#send_message"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "implemented" +note = "chat.update with the same block payload + text-only degrade on invalid_blocks/msg_blocks_too_long. Also the substrate for degraded-stream mid-edits and stream finalization." +source = ["crates/openab-core/src/slack.rs#edit_message"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "workaround" +note = "No delete_message override → the trait default (adapter.rs) edits the message to a zero-width space (\\u{200b}) via edit_message rather than calling chat.delete. Effectively blanks, doesn't remove." +source = ["crates/openab-core/src/adapter.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "implemented" +note = "reactions.add/reactions.remove; treats already_reacted/no_reaction as success (idempotent). Unicode→Slack shortname map is limited to a default set; unmapped emoji fall back to grey_question." +source = ["crates/openab-core/src/slack.rs#add_reaction", "crates/openab-core/src/slack.rs#remove_reaction"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "implemented" +note = "create_thread is a no-op mapping: returns a channel ref with thread_id = trigger ts (Slack threads are implicit, created by posting with thread_ts). No gateway create_topic (native adapter)." +source = ["crates/openab-core/src/slack.rs#create_thread"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "implemented" +note = "Images (download+encode), text files (5-file / 1 MB total cap, TEXT_FILE_COUNT_CAP/TEXT_TOTAL_CAP, mirroring Discord #291), audio → STT. Private files fetched with the bot token; failed images trigger a files:read/format hint message." +source = ["crates/openab-core/src/slack.rs#TEXT_FILE_COUNT_CAP", "crates/openab-core/src/slack.rs#TEXT_TOTAL_CAP"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "implemented" +note = "Audio attachments transcribed via media::download_and_transcribe when stt.enabled; transcript injected as a leading text block + best-effort echo (stt::post_echo). STT disabled → 🎤 reaction ack." +source = ["crates/openab-core/src/media.rs#download_and_transcribe", "crates/openab-core/src/stt.rs#post_echo"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "partial" +note = "Native adapter does NOT use the shared AdapterRouter::gate_incoming (L3 identity trust) — that's wired only for gateway and discord. Slack enforces inline allowlists: allowed_channels and allowed_users, plus trusted_bot_ids resolution (B…→U… via bots.info) for bot senders." +source = ["crates/openab-core/src/slack.rs#allowed_channels", "crates/openab-core/src/slack.rs#trusted_bot_ids_contains"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "partial" +note = "On a denied user, no text reply — reacts 🚫 to the offending message (add_reaction, inside the allowed_users deny branch). This is the native adapter's own deny UX, not the gateway's DenyIdentity ID-echo path." +source = ["crates/openab-core/src/slack.rs#add_reaction", "crates/openab-core/src/slack.rs#allowed_users"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "implemented" +note = "Per allow_user_messages: Mentions requires <@bot>; Involved requires bot participation in the thread; MultibotMentions additionally requires an @mention once another bot is in the thread. DMs are implicit mentions. app_mention handles the @-path (deduped against message)." +source = ["crates/openab-core/src/slack.rs#bot_participated_in_thread"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "not_implemented" +note = "slash_commands and interactive envelopes are ack'd and dropped: slash commands are blocked in thread composers and channel-level delivery lacks the thread_ts needed to route to a session. No in-text /reset//cancel parsing in the native path either (that lives in the gateway path)." +source = ["crates/openab-core/src/slack.rs"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "implemented" +note = "Eager other-bot detection on inbound bot messages (note_other_bot_in_thread), persisted to a disk cache (irreversible). Disables streaming (use_streaming/uses_native_streaming gate on other_bot_present) and, with MultibotMentions, requires @mention. Consecutive-bot-turn cap (MAX_CONSECUTIVE_BOT_TURNS = 1000) + BotTurnTracker soft/hard limits guard loops." +source = ["crates/openab-core/src/slack.rs#note_other_bot_in_thread", "crates/openab-core/src/slack.rs#MAX_CONSECUTIVE_BOT_TURNS"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Routed through Dispatcher; key is grouping-dependent — slack: in Thread mode or slack:: in Lane mode (Dispatcher::key), with thread_id falling back to channel_id outside a thread. Sender context is serialized with the Slack-native thread_ts key so agents calling the API directly see the right field." +source = ["crates/openab-core/src/dispatch.rs#key", "crates/openab-core/src/slack.rs"] +pr = "" + +[[openab_features]] +feature = "cron_dispatch" +status = "implemented" +note = "`cronjob.toml` jobs with `platform = \"slack\"` fire via the shared scheduler: `slack` is in `VALID_PLATFORMS` and `main` registers the Slack adapter under `\"slack\"` in `cron_adapters`, so `fire_cronjob` dispatches through `adapter.send_message`. Cron dispatch intentionally bypasses the L2/L3 ingress trust gate — jobs are operator-authored in config, not untrusted inbound." +source = ["crates/openab-core/src/cron.rs#VALID_PLATFORMS", "crates/openab-core/src/cron.rs#fire_cronjob", "src/main.rs#cron_adapters"] +pr = "" + + +# ═══ Schema 3 — platform-quirks ══════════════════════════════════════════════ + +[[quirks]] +date = "2026-07-04" +title = "Socket Mode keepalive (deaf-socket guard)" +note = "Slack's inbound WebSocket can go half-open (NAT idle-timeout silently drops inbound frames with no Close/FIN), leaving read.next() blocked forever so the reconnect loop never fires — the bot appears connected but goes deaf. The adapter pings every 30s (PING_INTERVAL_SECS) and force-reconnects if no inbound frame — including Slack's own pings — arrives within 75s (IDLE_TIMEOUT_SECS). Backoff doubles to a 30s cap, mirroring the gateway: 1,2,4,8,16,30,30…" +kind = "openab_decision" +source = "crates/openab-core/src/slack.rs#PING_INTERVAL_SECS" + +[[quirks]] +date = "2026-07-04" +title = "Native streaming duplication trap (#1055)" +note = "chat.stopStream's markdown_text appends, it does not replace. Passing the full reply at finish would duplicate the entire message. The adapter closes the stream content-free, then chat.updates the finalized Block Kit copy. On the active path a failed final chat.update must NOT fall back to postMessage (would post a duplicate); on the degraded (post+edit) path it must, since no streamed content exists." +kind = "openab_decision" +source = "crates/openab-core/src/slack.rs#uses_native_streaming" +refs = ["#1055"] + +[[quirks]] +date = "2026-07-04" +title = "Block Kit markdown vs legacy mrkdwn" +note = "Messages are sent as Block Kit markdown blocks (12k cap; real headings/tables/code fences), with a markdown_to_mrkdwn text fallback for notifications/a11y. message_limit is bumped 4000→11,900 to keep typical Markdown tables in one block; renders_native_tables()=true tells the router to skip the convert_tables pre-pass. Workspaces that reject the block (invalid_blocks/msg_blocks_too_long) get an automatic text-only retry (is_block_payload_rejected matches the trailing error code exactly, so invalid_blocks_field does not falsely trigger)." +kind = "openab_decision" +source = "crates/openab-core/src/slack.rs#is_block_payload_rejected" + +[[quirks]] +date = "2026-07-04" +title = "app_mention vs message dedup" +note = "Both app_mention and message events can fire for one @mention. The adapter routes the @-path through app_mention and skips mention-bearing message events (except in DMs, where app_mention doesn't fire). Eager multibot detection and bot-turn tracking run BEFORE the self/bot gates (own-message skip) so own and filtered-out bot messages still count and are still detected (mirrors Discord #481/#483)." +kind = "openab_decision" +source = "crates/openab-core/src/slack.rs#note_other_bot_in_thread" + +[[quirks]] +date = "2026-07-04" +title = "Positive-only, fail-closed caches" +note = "Participation and multibot are irreversible states, so their caches store positive results only; multibot is also disk-persisted (multibot_cache) to survive restarts. Thread-history checks (bot_participated_in_thread, and the AllowBots::All consecutive-bot loop cap) fail closed — an API error rejects the message rather than risk an unauthorized/looping response." +kind = "openab_decision" +source = "crates/openab-core/src/slack.rs#bot_participated_in_thread" + +[[quirks]] +date = "2026-07-04" +title = "Native trust divergence" +note = "Unlike the gateway/Discord paths (which call AdapterRouter::gate_incoming), the Slack adapter predates the unified trust gate and enforces access with inline allowed_channels/allowed_users/trusted_bot_ids checks. Deny UX is a 🚫 reaction, not the gateway's DenyIdentity identity-echo. A future consolidation onto AdapterRouter::with_trust would unify this." +kind = "openab_decision" +source = "crates/openab-core/src/adapter.rs#with_trust" + +[[quirks]] +date = "2026-07-04" +title = "files.upload sunset; separate 1 GB / 5 GB caps" +note = "files.upload sunsets 2025-11-12, replaced by files.getUploadURLExternal+files.completeUploadExternal; the 1 GB/file and 5 GB/free-workspace caps are separate, longstanding limits (the 5 GB cap dates to the 2019-03 changelog)." +kind = "intrinsic" +source = "https://docs.slack.dev/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay/" + +[[quirks]] +date = "2026-07-04" +title = "Socket Mode needs no per-event signature validation" +note = "Socket Mode is pre-authenticated by the xapp- app-level token (sent in the Authorization header to apps.connections.open); inbound events need no per-event HMAC/signature validation — the docs state this explicitly, unlike the HTTP Events API." +kind = "intrinsic" +source = "https://docs.slack.dev/apis/events-api/using-socket-mode" + +[[quirks]] +date = "2026-07-04" +title = "Developer slash commands are HTTP-only, not usable in threads" +note = "Developer slash commands cannot be invoked in message threads and are delivered over HTTP only — matching the adapter's decision to ack-and-drop slash_commands envelopes." +kind = "intrinsic" +source = "https://docs.slack.dev/interactivity/implementing-slash-commands" + +[[quirks]] +date = "2026-07-04" +title = "chat.update / chat.delete own-message-only constraints" +note = "chat.update fails with cant_update_message on non-own messages and edit_window_closed under workspace edit settings (Tier 3); chat.delete with a bot token deletes only that bot's own messages — which is why OpenAB's delete uses a zero-width-space edit fallback rather than assuming cross-author delete." +kind = "intrinsic" +source = "https://docs.slack.dev/reference/methods/chat.delete" + +[[quirks]] +date = "2026-07-04" +title = "chat.postMessage length limits (basis for 11,900 message_limit)" +note = "chat.postMessage limits: text recommended <=4,000 / truncated at 40,000; markdown_text (and the Block Kit markdown block) up to 12,000 — the basis for the 11,900 message_limit." +kind = "intrinsic" +source = "https://docs.slack.dev/reference/methods/chat.postMessage" + +[[quirks]] +date = "2026-07-04" +title = "Other bots' messages delivered as bot_message events" +note = "Other bots' messages ARE delivered as message events with subtype: bot_message + bot_id; OpenAB gates them via allow_bot_messages and trusted_bot_ids." +kind = "intrinsic" +source = "https://docs.slack.dev/reference/events/message/bot_message/" + +[[quirks]] +date = "2026-07-04" +title = "Proactive posting rate limit (1 msg/sec/channel)" +note = "Proactive posting rate = no more than 1 message/second/channel (bursts tolerated; chat.postMessage is a Special Tier method); relevant to multibot loop caps." +kind = "intrinsic" +source = "https://docs.slack.dev/apis/web-api/rate-limits/" + +[[quirks]] +date = "2026-07-04" +title = "chat.stopStream appends rather than replaces" +note = "chat.stopStream appends rather than replaces — passing full content at finish duplicated the whole reply; fixed by close-then-chat.update." +kind = "openab_decision" +source = "https://docs.slack.dev/reference/methods/chat.postMessage" +refs = ["#1055"] diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml new file mode 100644 index 000000000..42a325e85 --- /dev/null +++ b/docs/platforms/schema/teams.toml @@ -0,0 +1,344 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Platform capability schema — MICROSOFT TEAMS +# Generated from the adapter source + official docs. See _template.toml for the schema. +# ═══════════════════════════════════════════════════════════════════════════ + +schema_version = "2026-07-08" + +[platform] +name = "teams" +official_docs = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" +description = "Microsoft Teams bot reached via the Bot Framework / Azure Bot Connector REST activity protocol (not a direct Teams API)." + + +# ═══ Schema 1 — platform-capability (source of truth: official docs) ═════════ + +[capability.transport] +kind = "webhook" +note = "Bot Framework POSTs an `Activity` JSON to the bot's `/api/messages` messaging endpoint (one endpoint only)." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" + +[capability.inbound_auth] +scheme = "jwt_rs256" +note = "JWT bearer, RS256/RS384, signed by Bot Framework. L1 = OpenID Connect: fetch JWKS from `login.botframework.com` well-known config; validate `aud`=app_id, `iss`=`https://api.botframework.com`, `exp`, the `serviceurl` claim vs `activity.serviceUrl`, and channel endorsements." +source = "https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0" + +[capability.threads] +model = "native" +note = "Mixed: channel posts form native reply chains — `conversation.id` encodes the root message ID and replies land in that chain. 1:1 and group chats are flat (no sub-threads). `replyToId` is used for context/reply-target, not routing." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations" + +[capability.slash_commands] +supported = false +note = "No native `/`-command protocol for bots. A static command menu can be declared in the app manifest; selections arrive as ordinary `message` activities (plain text). Bots must parse commands from message text." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu" + +[capability.mentions] +method = "at_mention" +note = "`@mention` via `entities[]` of type `mention`; each mention entity carries `mentioned.id` + `mentioned.name`. In channel/group scope the bot only receives messages where it is @mentioned (unless RSC grants broader access). Bot detects itself by matching a mention's `mentioned.id` to `recipient.id`. Don't trust the text markup (``) — use `entities`." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations" + +[capability.emoji_reactions] +bot_can_add = true +bot_can_remove = true +bot_receives_events = true +note = "Bot receives reaction events via `messageReaction` activities (`reactionsAdded`/`reactionsRemoved`). Bot add/remove own reactions supported via SDK reaction APIs / connector." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-reactions" + +[capability.edit_message] +supported = true +note = "Bot can update its own already-sent message: `PUT /v3/conversations/{conversationId}/activities/{activityId}`. Requires caching the activityId returned by the original post." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages" + +[capability.delete_message] +supported = true +scope = "own" +note = "Own messages only: `DELETE /v3/conversations/{conversationId}/activities/{activityId}`. A bot cannot update or delete messages sent by users." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages" + +[capability.rich_content] +markdown = true +cards = true +buttons = true +note = "Markdown (`textFormat: markdown`), a subset of XML/HTML tags, and Adaptive Cards (buttons, inputs, images). Text-only messages don't support table formatting; rich cards support formatting in the `text` property only and don't support Markdown or tables. `suggestedActions` (`imBack` only, ≤6) work only in 1:1 chats and not alongside attachments." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages" + +[capability.attachments] +inbound = ["image", "file"] +outbound = ["image", "file"] +max_size_mb = 1 +note = "Inbound: user can attach pictures/files. Outbound pictures ≤ 1024×1024 px and ≤ 1 MB, PNG/JPEG/GIF (animated GIF not supported); Markdown inline image renders at 256×256 by default (override via XML width/height). Non-image files are shared via attachment/card links (Graph/SharePoint), not raw upload in the activity. The 1 MB cap is the outbound-picture limit." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" + +[capability.message_length_limit] +max_chars = 0 +note = "No fixed character count — a byte/UTF-16 budget, not a char cap. ~100 KB per bot message (approximate; UTF-16, includes text + image links + @mentions + reactions; excludes base64-encoded images). Recommend keeping the message ≤ 80 KB to guarantee delivery. Over-limit → `413 RequestEntityTooLarge` with error code `MessageSizeTooBig`." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages" + +[capability.dm_support] +supported = true +note = "1:1 personal chat (`conversationType: personal`)." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" + +[capability.group_model] +kinds = ["personal", "groupChat", "channel"] +note = "Taxonomy: `personal` (1:1), `groupChat` (group chat), `channel` (team channel, has reply chains + `channelData.team`/`channel`). Bot install scopes: `personal`, `groupChat`/`groupchat`, `team`." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations" + +[capability.group_sender_identity] +stable_id = "yes" +note = "`activity.from.id` (`29:1...`) is a stable, always-present per-user id in group/channel events. `from.aadObjectId` (Entra object id) is also provided but may be absent for guests/anonymous; not consent-gated for basic id." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations" + +[capability.send_model] +model = "hybrid" +reply_token_ttl_sec = 0 +max_objects_per_send = 0 +note = "Reply + proactive. Reply: POST activity to `v3/conversations/{id}/activities` using the per-conversation `serviceUrl`. `serviceUrl` can change and should be refreshed per inbound activity (no fixed reply-window token; OAuth token TTL ~ `expires_in`)." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Allowed if the app is installed for the target scope (else `403 ForbiddenOperationException`/`BotNotInConversationRoster`). Per-bot-per-thread send-to-conversation: 7/1s, 8/2s, 60/30s, 1800/3600s; per-app-per-tenant global 50 RPS. Over-limit → `429 Too Many Requests` (also retry `412`/`502`/`504`); use exponential backoff." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit" + +[capability.bot_to_bot] +delivered = false +note = "Teams does not deliver other bots' messages to a bot; bots respond to user activities only (@mention-gated in groups/channels)." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" + +[capability.typing_indicator] +supported = true +note = "Bot can send a `typing` activity via the connector. Not currently emitted by the OpenAB adapter." +source = "https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0" + + +# ═══ Schema 2 — openab-feature-support (source of truth: our code + PR) ══════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "Core `GatewayAdapter::send_message` → `send_gateway_reply` → gateway `handle_reply` → `send_activity` POSTs a `message` activity with `textFormat: markdown`." +source = ["crates/openab-core/src/gateway.rs#send_message", "crates/openab-gateway/src/adapters/teams.rs#send_activity"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "partial" +note = "Core splits via `split_delivery`; `GatewayAdapter::message_limit()` returns 4096 (hardcoded 'Telegram limit', not Teams' ~100 KB / UTF-16 budget) — chunking works but the bound is generic, not Teams-tuned." +source = ["crates/openab-core/src/adapter.rs#message_limit", "crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "workaround" +note = "Gateway platforms use core's post+edit cosmetic streaming (`use_streaming` just returns the configured `streaming` flag; no native streaming API). But the Teams gateway never dispatches `edit_message`, so streaming edits don't actually reach Teams — effectively send-once. `update_activity` (PUT) exists in the adapter but is unwired dead code." +source = ["crates/openab-core/src/gateway.rs#use_streaming", "crates/openab-gateway/src/adapters/teams.rs#update_activity"] +pr = "" + +[[openab_features]] +feature = "reply_quote" +status = "not_implemented" +note = "`GatewayAdapter::send_message_with_reply` puts the target id into `quote_message_id` (the visual-quote field via `send_gateway_reply`), not `reply_to`. The Teams adapter reads only `reply.reply_to` (the triggering-event/origin id) → `replyToId`, and ignores `quote_message_id` entirely — so the intended visual quote never reaches Teams. `replyToId` is a reply-target/context id, not a visual quote." +source = ["crates/openab-core/src/gateway.rs#send_message_with_reply", "crates/openab-gateway/src/adapters/teams.rs#handle_reply"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "not_implemented" +note = "Core default/`GatewayAdapter::edit_message` emits an `edit_message` command, but Teams is not in `EDIT_RESPONSE_PLATFORMS` (fire-and-forget) AND `handle_reply` has no `edit_message` branch — it falls through to `send_activity`, posting the new text as a fresh message." +source = ["crates/openab-core/src/gateway.rs#edit_message", "crates/openab-gateway/src/adapters/teams.rs#handle_reply"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "not_implemented" +note = "The `delete_message` command is not handled in `handle_reply`; it falls through to `send_activity`. Platform supports DELETE, but the adapter never calls it." +source = ["crates/openab-core/src/gateway.rs#delete_message", "crates/openab-gateway/src/adapters/teams.rs#handle_reply"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "not_implemented" +note = "`handle_reply` explicitly early-returns (silently ignores) `add_reaction`/`remove_reaction`. Platform supports bot reactions, but OpenAB does not send them for Teams." +source = ["crates/openab-gateway/src/adapters/teams.rs#handle_reply"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "not_implemented" +note = "The `create_topic` command from `GatewayAdapter::create_thread` is not handled by Teams `handle_reply` (falls through to plain send). Inbound events set `thread_id: None` ('Teams conversations don't have sub-threads in the same way'). Core `create_thread` falls back to the same channel on timeout anyway." +source = ["crates/openab-core/src/gateway.rs#create_thread", "crates/openab-gateway/src/adapters/teams.rs#handle_reply"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "not_implemented" +note = "Webhook only reads `activity.text`; attachments are neither parsed nor forwarded (`mentions` passed as empty `vec![]`; no attachment extraction). The `ChannelAccount`/`Activity` DTOs don't even model `attachments`." +source = ["crates/openab-gateway/src/adapters/teams.rs#Activity"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "n_a" +note = "No voice-note ingestion path in the adapter; not applicable." +source = ["crates/openab-gateway/src/adapters/teams.rs"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "implemented" +note = "Two layers: platform-level `check_tenant` (optional `allowed_tenants` allowlist) at ingress, plus core's shared `gate_incoming` (L2 scope + L3 identity) applied to all gateway events in `process_gateway_event`." +source = ["crates/openab-gateway/src/adapters/teams.rs#check_tenant", "crates/openab-core/src/adapter.rs#gate_incoming"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "implemented" +note = "On `DenyIdentity`, core echoes the sender their ID (throttled via `echo_allowed`). Delivered through the gateway send path, so subject to the same reply constraints as normal sends." +source = ["crates/openab-core/src/gateway.rs#echo_allowed"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "partial" +note = "Core `should_skip_event` enforces @mention gating in groups when `bot_username` is set — but the Teams webhook forwards `mentions: vec![]` ('@mentions parsing deferred to future PR'), so gating can't match a Teams mention. It also only fires for `channel_type` `group`/`supergroup`; Teams sends `groupChat`/`channel`, which don't match. Teams itself only delivers @mentioned messages in channels, which mitigates this at the platform layer." +source = ["crates/openab-core/src/gateway.rs#should_skip_event", "crates/openab-gateway/src/adapters/teams.rs#handle_reply"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "implemented" +note = "`/reset` and `/cancel` are parsed from message text by core's gateway loops (WS path + unified `process_gateway_event`); no native Teams slash protocol needed." +source = ["crates/openab-core/src/gateway.rs#process_gateway_event"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "partial" +note = "Core supports multi-bot suppression of streaming (`use_streaming(other_bot_present)`); moot on Teams because streaming edits don't reach it and Teams doesn't deliver other bots' messages anyway." +source = ["crates/openab-core/src/gateway.rs#use_streaming", "crates/openab-core/src/adapter.rs#use_streaming"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Session keyed by `conversation.id` (+ `conversation_type`); `serviceUrl` cached per conversation for reply routing, refreshed (timestamp) on each reply, with a periodic TTL cleanup task in the gateway." +source = ["crates/openab-gateway/src/adapters/teams.rs#handle_reply", "crates/openab-gateway/src/lib.rs"] +pr = "" + +[[openab_features]] +feature = "cron_dispatch" +status = "not_implemented" +note = "Not wired for scheduled cron dispatch: `VALID_PLATFORMS` (cron.rs) covers only discord/slack/telegram and no adapter is registered for this platform in `cron_adapters`, so `cronjob.toml` jobs targeting it are rejected at startup by `validate_cronjobs`." +source = [] +pr = "" + + +# ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ + +[[quirks]] +date = "2026-07-04" +title = "serviceUrl is per-conversation and must be cached/refreshed" +note = "Teams replies are POSTed to a `serviceUrl` that arrives on each inbound activity and can change over time. The adapter caches `conversation.id → (serviceUrl, timestamp)` on ingress and refreshes the timestamp on every reply to avoid TTL expiry mid-conversation; a background task in the gateway evicts stale entries (4 h TTL). If an inbound activity lacks `serviceUrl`, the event is dropped (can't route replies)." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" + +[[quirks]] +date = "2026-07-04" +title = "Sender identity: from.id vs aadObjectId" +note = "Verified: the adapter uses `activity.from.id` (the `29:1abc...` Bot Framework/Teams user id) as `SenderInfo.id`, not `from.aadObjectId`. `aadObjectId` (Entra object id) is deserialized but unused — it can be null for guests/anonymous users, whereas `from.id` is always present and stable, so it's the correct trust-gate key. Tenant is resolved with fallbacks: top-level `tenant.id` → `channelData.tenant.id` → `conversation.tenantId` because Teams places it differently for personal vs channel webhooks (tests pin this)." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" + +[[quirks]] +date = "2026-07-04" +title = "The reply/quote target is dropped" +note = "`GatewayAdapter::send_message_with_reply` carries the visual-quote target in `quote_message_id` (set from `reply_to_message_id`), but the Teams adapter only reads `reply.reply_to` (the origin/triggering-event id) and maps it to `replyToId`. It never reads `quote_message_id`, so a caller asking for a visual reply/quote gets a plain reply-target `replyToId` at best and no visual quote. This is a distinct gap from the write-side commands." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" + +[[quirks]] +date = "2026-07-04" +title = "Auth is heavier than most adapters (endorsements + serviceUrl claim)" +note = "JWT validation goes beyond signature/aud/iss/exp: it also enforces (B2) that the signing JWK endorses the activity's `channelId` and (B1) that the token's `serviceurl` claim equals the activity's `serviceUrl` — binding the token to the specific channel/service origin. JWKS keys are cached (1 h TTL) with a force-refresh-on-cache-miss path for Microsoft key rotation. The activity body is parsed before JWT auth (Bot Framework needs `serviceUrl`/`channelId` from the body to validate) — this is why the pre-auth body is capped at 256 KB." +kind = "intrinsic" +source = "https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0" + +[[quirks]] +date = "2026-07-04" +title = "Write-side commands are largely unimplemented" +note = "`edit_message`, `delete_message`, `create_topic`, and reactions are all issued by core but the Teams `handle_reply` only special-cases reactions (drop) — everything non-reaction is treated as a plain send. This means streaming (post+edit), thread creation, and message edit/delete are effectively no-ops or mis-sends on Teams today, despite the platform supporting all of them (and despite `update_activity` existing as dead code). This is the main gap for a future PR." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" + +[[quirks]] +date = "2026-07-04" +title = "Bot message budget is ~100 KB UTF-16" +note = "Bot message budget is ~100 KB UTF-16 (text + image links + mentions + reactions, excl. base64 images); recommend ≤80 KB, over-limit returns `413 RequestEntityTooLarge` / `MessageSizeTooBig`." +kind = "intrinsic" +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages" + +[[quirks]] +date = "2026-07-04" +title = "Send rate limits" +note = "Per-bot-per-thread send limits 7/1s, 8/2s, 60/30s, 1800/3600s; global 50 RPS per app per tenant; throttle → `429` (retry `412`/`502`/`504` too), use exponential backoff." +kind = "intrinsic" +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit" + +[[quirks]] +date = "2026-07-04" +title = "suggestedActions constraints" +note = "`suggestedActions` support `imBack` only, ≤6 buttons, one-on-one chats only, and not alongside attachments (any conversation type)." +kind = "intrinsic" +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" + +[[quirks]] +date = "2026-07-04" +title = "Outbound picture limits" +note = "Outbound pictures ≤1024×1024 px, ≤1 MB, PNG/JPEG/GIF; animated GIF unsupported; Markdown inline image defaults to 256×256." +kind = "intrinsic" +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" + +[[quirks]] +date = "2026-07-04" +title = "Reactions supported by platform, unused by OpenAB" +note = "Bots can add/remove reactions and receive `messageReaction` (`reactionsAdded`/`reactionsRemoved`) events; OpenAB uses neither for Teams." +kind = "intrinsic" +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-reactions" + +[[quirks]] +date = "2026-07-04" +title = "Bot can edit/delete only its own messages" +note = "Bot can edit (`PUT .../activities/{activityId}`) and delete (`DELETE .../activities/{activityId}`) its own messages but never user messages." +kind = "intrinsic" +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages" + +[[quirks]] +date = "2026-07-04" +title = "No native bot slash-command protocol" +note = "No native bot slash-command protocol; command menus arrive as plain `message` activities and must be text-parsed." +kind = "intrinsic" +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu" + +[[quirks]] +date = "2026-07-04" +title = "Bot only receives @mentioned messages in channels/group chats" +note = "In channels/group chats a bot only receives messages where it is @mentioned (unless RSC); mentions live in `entities[]` (`type: mention`, `mentioned.id`/`.name`), not the text markup." +kind = "intrinsic" +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations" + +[[quirks]] +date = "2026-07-04" +title = "Verified sender id = activity.from.id" +note = "Verified sender id = `activity.from.id` (`29:...`), not `aadObjectId`; adapter forwards `from.id` as trust-gate key." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/teams.rs" + +[[quirks]] +date = "2026-07-04" +title = "handle_reply only plain sends + drops reactions" +note = "Teams `handle_reply` only handles plain sends + drops reactions; `edit_message`/`delete_message`/`create_topic` are undispatched (fall through to `send_activity`), `quote_message_id` is ignored, and inbound attachments/mentions are not parsed — main gaps for a follow-up PR." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/teams.rs" diff --git a/docs/platforms/schema/telegram.toml b/docs/platforms/schema/telegram.toml new file mode 100644 index 000000000..4395e6877 --- /dev/null +++ b/docs/platforms/schema/telegram.toml @@ -0,0 +1,360 @@ +# Generated from the adapter source + official docs — see docs/platforms/README.md for schemas. + +schema_version = "2026-07-08" + +[platform] +name = "telegram" +official_docs = "https://core.telegram.org/bots/api" +description = "Telegram Bot API — webhook-delivered Update objects for bots in DMs, groups, supergroups (with forum topics), and channels." + + +# ═══ Schema 1 — platform-capability ══════════════════════════════════════════ + +[capability.transport] +kind = "webhook" +note = "JSON POST of `Update`; long-poll `getUpdates` also exists but OpenAB uses webhook." +source = "https://core.telegram.org/bots/api#getting-updates" + +[capability.inbound_auth] +scheme = "shared_secret" +note = "L1 only: `X-Telegram-Bot-Api-Secret-Token` header (opaque shared secret set on `setWebhook`) + optional source-IP allowlist (149.154.160.0/20, 91.108.4.0/22). No HMAC/body signature." +source = "https://core.telegram.org/bots/webhooks" + +[capability.threads] +model = "native" +note = "Native forum topics only: supergroups with topics enabled carry `message_thread_id`, created via `createForumTopic`. Non-forum chats have no threading (reply-to quoting exists separately)." +source = "https://core.telegram.org/bots/api#createforumtopic" + +[capability.slash_commands] +supported = true +note = "`/command` text arrives as a `bot_command` entity; discoverable list registered via `setMyCommands`. Command names ≤32 chars, Latin letters/digits/underscores. Delivery is just message text — the bot parses it." +source = "https://core.telegram.org/bots/api#setmycommands" + +[capability.mentions] +method = "username" +note = "Two entity types: `mention` (`@username`) and `text_mention` (users without a username, carries a `User`). Privacy mode ON limits which messages a group bot sees (commands meant for it, replies, service messages, etc.)." +source = "https://core.telegram.org/bots/api#messageentity" + +[capability.emoji_reactions] +bot_can_add = true +bot_can_remove = true +bot_receives_events = true +note = "Add/set via `setMessageReaction` (replaces the bot's whole reaction set; non-premium limited to one emoji from the allowed set); remove = set to empty. Receiving `message_reaction` requires the bot be a chat admin + explicit `message_reaction` in `allowed_updates`, and 'The update isn't received for reactions set by bots.'" +source = "https://core.telegram.org/bots/api#setmessagereaction" + +[capability.edit_message] +supported = true +note = "`editMessageText` (also `editMessageCaption`/`editMessageMedia`) on the bot's own messages." +source = "https://core.telegram.org/bots/api#editmessagetext" + +[capability.delete_message] +supported = true +scope = "own_and_others" +note = "`deleteMessage`: a bot can delete its own outgoing messages; others' only with admin `can_delete_messages`. A message can only be deleted if sent less than 48 hours ago (with narrow exceptions: service messages / a bot's own messages / channel/anonymous-admin cases)." +source = "https://core.telegram.org/bots/api#deletemessage" + +[capability.rich_content] +markdown = true +cards = false +buttons = true +note = "Markdown/MarkdownV2 + HTML `parse_mode`; inline keyboards / reply keyboards / callback buttons. Bot API 10.1 (2026-06-11) added `sendRichMessage` + `InputRichMessage` for GFM-style rich blocks (tables, headings, syntax-highlighted code) — see quirks. No legacy 'card' object beyond keyboards." +source = "https://core.telegram.org/bots/api#formatting-options" + +[capability.attachments] +inbound = ["image", "audio", "video", "file"] +outbound = ["image", "audio", "video", "file"] +max_size_mb = 20 +note = "inbound: photo, document, voice, audio, video, etc.; outbound: photo/document/etc. Download via `getFile` capped at 20 MB on the cloud Bot API (up to 2 GB only with a self-hosted local Bot API server). Send caps: photos ~10 MB, other files 50 MB." +source = "https://core.telegram.org/bots/api#getfile" + +[capability.message_length_limit] +max_chars = 4096 +note = "`sendMessage` text: 1–4096 UTF-8 chars; captions 1024. Longer plain text must be chunked. Rich messages via `sendRichMessage` accept larger content — see quirks; exact ceiling ? (unverified from truncated docs)." +source = "https://core.telegram.org/bots/api#sendmessage" + +[capability.dm_support] +supported = true +note = "1:1 private chat; the user must `/start` the bot first — bots cannot initiate a private chat." +source = "https://core.telegram.org/bots/faq" + +[capability.group_model] +kinds = ["private", "group", "supergroup", "channel"] +note = "private (DM), group, supergroup (optionally forum = topics), channel. `chat.type` distinguishes them." +source = "https://core.telegram.org/bots/api#chat" + +[capability.group_sender_identity] +stable_id = "yes" +note = "Stable numeric `from.id` present on group messages (not consent-gated), unless sent on behalf of a chat (`sender_chat`). Privacy mode limits which messages the bot sees, not the identity of the ones it does." +source = "https://core.telegram.org/bots/api#message" + +[capability.send_model] +model = "push_only" +note = "Push model: the bot may send to any chat it shares with the user at any time (no reply-window/TTL). Reply/quote via `reply_parameters`. Rate-limited (see proactive_push)." +source = "https://core.telegram.org/bots/api#sendmessage" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Allowed to any user who has started the bot / any group it's in. Rate limits (per FAQ): avoid >1 msg/s per chat; bulk broadcast not more than ~30 msg/s overall; not more than 20 msg/min in a group. No reply-window gating." +source = "https://core.telegram.org/bots/faq" + +[capability.bot_to_bot] +delivered = false +note = "Per FAQ, 'Bots will not be able to see messages from other bots regardless of mode.' `is_bot` is present on `from` for the rare forwarded/quoted cases." +source = "https://core.telegram.org/bots/faq" + +[capability.typing_indicator] +supported = true +note = "`sendChatAction` (e.g. `typing`) auto-clears after ~5s or on next message. Not used by the OpenAB adapter." +source = "https://core.telegram.org/bots/api#sendchataction" + + +# ═══ Schema 2 — openab-feature-support ═══════════════════════════════════════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "`sendMessage` with `parse_mode: Markdown`; on a Markdown parse error (`is_markdown_parse_error`) retries once as plain text." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply", "crates/openab-gateway/src/adapters/telegram.rs#is_markdown_parse_error"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "implemented" +note = "`chunk_text` splits at 4096 chars, preferring newline boundaries; hard-splits a single over-limit line char-by-char. (Adapter-local, not the trait's `split_delivery`.)" +source = ["crates/openab-gateway/src/adapters/telegram.rs#chunk_text"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "partial" +note = "No legacy streaming API; streaming maps onto `editMessageText` when `reply_to` is a real message_id. When `reply_to == \"draft\"` and `rich_messages` is on, it calls `sendRichMessageDraft` (skips updates <30 chars, truncates at 32768 via `floor_char_boundary`). That draft fn `send_rich_message_draft` is `#[allow(dead_code)]` — 'Wired but unused until gateway streaming infrastructure integrates' — so the draft path is not yet driven by gateway streaming." +source = ["crates/openab-gateway/src/adapters/telegram.rs#send_rich_message_draft"] +pr = "" + +[[openab_features]] +feature = "reply_quote" +status = "partial" +note = "`GatewayReply` carries a `quote_message_id`, but the Telegram send path does not attach `reply_to_message_id`/`reply_parameters`; outbound only sets `message_thread_id` (forum-topic routing). So agent quote-replies aren't rendered as native quotes." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply", "crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "implemented" +note = "`command == \"edit_message\"` → `editMessageText` (real msg_id) or, for a `\"draft\"` ref with `rich_messages` on, `sendRichMessageDraft`." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "not_implemented" +note = "No `deleteMessage` call anywhere in the adapter and no delete command handled in `handle_reply` (dispatch covers only create_topic / edit_message / reactions / send). Telegram supports it natively; simply not wired. The trait's default `delete_message` returns an unsupported error." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply", "crates/openab-core/src/adapter.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "workaround" +note = "`add_reaction`/`remove_reaction` → `setMessageReaction`. Because non-premium chats allow only ONE reaction, the adapter keeps a local `reaction_state` map and always sends the full replacing set; the mood-face emojis (😊😎🫡🤓😏✌️💪🦾) are dropped so the terminal 👍 'done' marker isn't clobbered, and 🆗→👍 is remapped." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "implemented" +note = "`command == \"create_topic\"` → `createForumTopic`, returns `message_thread_id` in a `GatewayResponse`. The trait's `create_thread` issues that command with a 5s timeout and falls back to the same channel on failure/timeout; core ingress only creates a topic for supergroups with no existing `thread_id`." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply", "crates/openab-core/src/gateway.rs#create_thread"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "implemented" +note = "photo (largest by `width*height`), document (text-only, extension-checked + UTF-8 validated), voice, audio downloaded via `getFile`; size-gated against `IMAGE/AUDIO/FILE_MAX_DOWNLOAD` (Content-Length pre-check and post-read); images resized/compressed; non-text or binary docs rejected with a reason." +source = ["crates/openab-gateway/src/adapters/telegram.rs#download_telegram_media", "crates/openab-gateway/src/media.rs#FILE_MAX_DOWNLOAD"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "partial" +note = "The adapter only downloads voice/audio as an `audio` attachment (`MediaKind::Audio`, `download_telegram_media`); it does no transcription. STT happens downstream in core: batched / per-message paths call `download_and_transcribe` when `stt_config.enabled`, injecting a `[Voice message transcript]:` block." +source = ["crates/openab-gateway/src/adapters/telegram.rs#download_telegram_media", "crates/openab-core/src/media.rs#download_and_transcribe"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "partial" +note = "Adapter enforces L1 only: `secret_token` header + optional Telegram source-subnet check (`telegram_trusted_source_only`; IP extraction is phase-1 'observe', trusting CF/X-Real-IP then the spoofable leftmost XFF). L2 scope / L3 identity allowlists are enforced by the shared core ingress trust gate, not the adapter." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply", "crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "implemented" +note = "Not in the adapter — handled by core ingress: an L3 `DenyIdentity` decision echoes the sender their ID ('Your ID: … Ask the admin to add it to allowed_users'), throttled to one echo per (platform, sender) per `ECHO_WINDOW` (300s). `DenyScope` is a silent drop." +source = ["crates/openab-core/src/gateway.rs#ECHO_WINDOW"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "implemented" +note = "In groups/supergroups without a thread, core `should_skip_event` requires an `@bot_username` mention; in-thread events bypass the gate. The adapter extracts `mention` entities into `event.mentions`." +source = ["crates/openab-core/src/gateway.rs#should_skip_event", "crates/openab-gateway/src/adapters/telegram.rs"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "implemented" +note = "`/reset` and `/cancel` handled in core gateway (both the batched consumer path and the per-message path). The adapter has no per-command parsing beyond forwarding text." +source = ["crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "implemented" +note = "`should_skip_event` drops other bots' events unless the sender id is in `trusted_bot_ids`; the adapter forwards `is_bot` from `from`. (Telegram rarely delivers other bots' messages anyway.)" +source = ["crates/openab-core/src/gateway.rs#should_skip_event", "crates/openab-gateway/src/adapters/telegram.rs"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Session keyed by `chat.id` + optional `message_thread_id`; `compute_draft_id` derives a stable per-(chat,thread) draft id to avoid forum-topic collisions." +source = ["crates/openab-gateway/src/adapters/telegram.rs#compute_draft_id"] +pr = "" + +[[openab_features]] +feature = "cron_dispatch" +status = "implemented" +note = "`cronjob.toml` jobs with `platform = \"telegram\"` fire via the shared scheduler: `telegram` was added to `VALID_PLATFORMS` and the unified adapter registered under `\"telegram\"` in `cron_adapters` (PR #1315), so `fire_cronjob` dispatches through `adapter.send_message`. Like the other platforms, cron dispatch bypasses the L2/L3 ingress trust gate — jobs are operator-authored in config, not untrusted inbound." +source = ["crates/openab-core/src/cron.rs#VALID_PLATFORMS", "crates/openab-core/src/cron.rs#fire_cronjob", "src/main.rs#cron_adapters"] +pr = "#1315" + + +# ═══ Schema 3 — platform-quirks ══════════════════════════════════════════════ + +[[quirks]] +date = "2026-07-04" +title = "'Rich Message' API is real (Bot API 10.1) — but the adapter path is flag-gated and partly dead-code" +note = """ +The adapter's `sendRichMessage` / `sendRichMessageDraft` / `InputRichMessage.markdown` calls correspond to methods genuinely added in Bot API 10.1 (2026-06-11): 'Added the method `sendRichMessage`…', 'Added the method `sendRichMessageDraft`, allowing bots to stream partial rich messages', 'Added the class `InputRichMessage`…'. This corrects an earlier assumption that the path was fictional. Caveats that remain code-side, not API-side: +- The path is gated behind the adapter's `rich_messages` flag and falls back to legacy chunked `sendMessage` on any error (`is_complex_markdown` picks headings/GFM tables/over-4096-char text as candidates; code blocks are deliberately routed to legacy `sendMessage` to keep syntax highlighting). +- `send_rich_message_draft` is `#[allow(dead_code)]` and not yet driven by gateway streaming. +- The exact 32768-char limit and the precise `InputRichMessage` field names (`markdown` vs `html`) the adapter assumes could not be confirmed from the official page (method-body sections were truncated on fetch); confirm against a full 10.1 docs / Bot API server before relying on the 32768 truncation and the html/markdown branch. +""" +kind = "intrinsic" +source = "https://core.telegram.org/bots/api-changelog" + +[[quirks]] +date = "2026-07-04" +title = "Reactions are single-slot, non-additive" +note = """ +Telegram non-premium chats allow only one reaction per message. The adapter maintains `reaction_state` and always PUTs the full replacing set via `setMessageReaction`; multi-emoji 'mood' reactions used on Discord are deliberately dropped so the terminal 👍 isn't clobbered. Consequence: OpenAB's add/remove reaction semantics are emulated, not literal. Separately, inbound reaction events (`message_reaction`) require the bot be a chat admin + opt in via `allowed_updates`, and are never delivered for reactions set by bots — the adapter does not subscribe to them. +""" +kind = "intrinsic" +source = "https://core.telegram.org/bots/api#setmessagereaction" + +[[quirks]] +date = "2026-07-04" +title = "Auth is L1-only and IP extraction is phase-1" +note = """ +Only `secret_token` + an optional source-subnet check gate the webhook. Subnet enforcement is opt-in (`telegram_trusted_source_only`), and IP extraction trusts `CF-Connecting-IP` / `X-Real-IP` then falls back to the spoofable leftmost `X-Forwarded-For` — a documented phase-2 gap. Real identity/scope trust lives in core ingress (L2 scope / L3 identity), and the deny-echo lives there too. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/telegram.rs#handle_reply" + +[[quirks]] +date = "2026-07-04" +title = "20 MB inbound download ceiling" +note = """ +`getFile` on the cloud Bot API can't serve files >20 MB; large media will fail to download regardless of OpenAB's own `*_MAX_DOWNLOAD` limits (10/20/20 MB). A self-hosted local Bot API server is required for up to 2 GB. STT downstream imposes its own 25 MB (Whisper) cap in core. +""" +kind = "intrinsic" +source = "https://core.telegram.org/bots/api#getfile" + +[[quirks]] +date = "2026-07-04" +title = "Outbound quotes aren't native" +note = """ +`GatewayReply.quote_message_id` exists, but the Telegram send path never sets `reply_to_message_id`/`reply_parameters` — it only sets `message_thread_id`. Agent quote-replies therefore land in the right topic but aren't rendered as native Telegram quotes. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/telegram.rs#handle_reply" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: Bot API 10.1 rich-message methods are real" +note = "Bot API 10.1 (2026-06-11) added `sendRichMessage`, `sendRichMessageDraft`, and `InputRichMessage` — the adapter's 'rich' path targets a REAL API, not a fictional one (corrects prior draft). Exact 32768 limit / field names still unverified (docs truncated on fetch)." +kind = "intrinsic" +source = "https://core.telegram.org/bots/api-changelog" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: getFile 20 MB cloud cap" +note = "Telegram cloud `getFile` caps downloads at 20 MB; up to 2 GB needs a self-hosted local Bot API server." +kind = "intrinsic" +source = "https://core.telegram.org/bots/api#getfile" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: deleteMessage 48h window" +note = "`deleteMessage` generally only works within 48h for a bot's outgoing messages; deleting others' needs admin `can_delete_messages`." +kind = "intrinsic" +source = "https://core.telegram.org/bots/api#deletemessage" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: message_reaction delivery constraints" +note = "`message_reaction` updates require the bot be a chat admin + opt-in via `allowed_updates`, and 'The update isn't received for reactions set by bots.'" +kind = "intrinsic" +source = "https://core.telegram.org/bots/api#update" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: sendMessage 4096 char limit" +note = "`sendMessage` text limit is 1–4096 UTF-8 chars; adapter chunks at 4096." +kind = "intrinsic" +source = "https://core.telegram.org/bots/api#sendmessage" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: broadcasting rate limits" +note = "Broadcasting limits: ~1 msg/s per chat, ~30 msg/s overall bulk, 20 msg/min per group." +kind = "intrinsic" +source = "https://core.telegram.org/bots/faq" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: privacy mode group visibility" +note = "With privacy mode ON, a group bot receives only `/cmd@bot` commands, general commands if it messaged last, replies to it, inline messages via it, and all service messages." +kind = "intrinsic" +source = "https://core.telegram.org/bots/features#privacy-mode" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: delete_message unimplemented despite native support" +note = "`delete_message` is unimplemented in the Telegram adapter despite native support — no `deleteMessage` call or command handler; trait default returns unsupported." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/telegram.rs#handle_reply" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: outbound send never sets reply_to_message_id" +note = "Outbound send sets `message_thread_id` for topic routing but never `reply_to_message_id`, so agent quote-replies aren't native quotes." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/telegram.rs#handle_reply" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: MAX_DOWNLOAD constants live in gateway crate" +note = "MAX_DOWNLOAD constants (IMAGE 10 / FILE 20 / AUDIO 20 MB) live in the gateway crate, not core; core adds a separate 25 MB Whisper STT cap." +kind = "openab_decision" +source = "crates/openab-gateway/src/media.rs#FILE_MAX_DOWNLOAD" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: STT done in core, not the adapter" +note = "Voice/audio STT is not done in the adapter; core transcribes `audio` attachments via `download_and_transcribe` when `stt_config.enabled`." +kind = "openab_decision" +source = "crates/openab-core/src/media.rs#download_and_transcribe" diff --git a/docs/platforms/schema/wecom.toml b/docs/platforms/schema/wecom.toml new file mode 100644 index 000000000..9a2c0e2c0 --- /dev/null +++ b/docs/platforms/schema/wecom.toml @@ -0,0 +1,315 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Platform capability schema — WECOM (WeCom / WeChat Work) +# ═══════════════════════════════════════════════════════════════════════════ + +schema_version = "2026-07-08" + +[platform] +name = "wecom" +official_docs = "https://developer.work.weixin.qq.com/document/path/90664" +description = "WeCom (企業微信 / WeChat Work) self-built app (自建应用 / agentid) callback integration — AES-encrypted 1:1 member callbacks, proactive push via message/send." + + +# ═══ Schema 1 — platform-capability (source of truth: official docs) ═════════ + +[capability.transport] +kind = "webhook" +note = "HTTP callback: WeCom POSTs AES-encrypted XML to the configured self-built-app callback URL." +source = "https://developer.work.weixin.qq.com/document/path/90238" + +[capability.inbound_auth] +scheme = "aes" +note = "L1: msg_signature = SHA1 of sort(token, timestamp, nonce, encrypt).concat(); body is AES-256-CBC decrypted with the 43-char EncodingAESKey (base64→32 bytes), IV = first 16 key bytes, WeCom PKCS7 block_size=32; inner corp_id suffix validated." +source = "https://developer.work.weixin.qq.com/document/path/90238" + +[capability.threads] +model = "none" +note = "Self-built app callback is a flat 1:1 conversation; no thread/topic primitive." +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[capability.slash_commands] +supported = false +note = "Not a platform primitive. No command registration/delivery; text like /reset arrives as ordinary text content and any interpretation is OpenAB-side." +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[capability.mentions] +method = "none" +note = "n/a in the self-built app 1:1 model — every callback is a direct message from one FromUserName; no @mention concept. (Group @mention exists only under the separate 智能机器人 model, which this adapter does not use.)" +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[capability.emoji_reactions] +bot_can_add = false +bot_can_remove = false +bot_receives_events = false +note = "No reaction API for self-built apps: bot cannot add or remove reactions, and reaction events are not delivered. The receive doc enumerates only six inbound callback types: text, image, voice, video, location, link (no reaction, no edit)." +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[capability.edit_message] +supported = false +note = "No edit API. The only mutation is recall (/cgi-bin/message/recall), which deletes rather than edits, and only within 24h on messages this app sent." +source = "https://developer.work.weixin.qq.com/document/path/94867" + +[capability.delete_message] +supported = true +scope = "own" +window_sec = 86400 +note = "Own messages only, via recall (/cgi-bin/message/recall), within 24 hours of send; cannot delete users'/others' messages (data already delivered to the WeChat-plugin end also can't be pulled back)." +source = "https://developer.work.weixin.qq.com/document/path/94867" + +[capability.rich_content] +markdown = true +cards = true +buttons = false +note = "Supported outbound msgtypes: text, image, voice, video, file, textcard, news (图文), mpnews, markdown, miniprogram_notice, template_card. Adapter uses only text." +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[capability.attachments] +inbound = ["image", "audio", "video", "file"] +outbound = ["image", "audio", "video", "file"] +max_size_mb = 20 +note = "Outbound media upload caps (media/upload): image ≤10MB (JPG/PNG), voice ≤2MB/60s (AMR), video ≤10MB (MP4), general file ≤20MB; min 5 bytes; temp media_id valid 3 days. Inbound: image/voice/video callbacks carry a MediaId pulled via media/get (same 3-day validity). Note: the receive doc's six enumerated inbound types are text/image/voice/video/location/link — inbound file (MediaId + FileName) is not in that list yet is delivered in practice and handled by the adapter." +source = "https://developer.work.weixin.qq.com/document/path/90253" + +[capability.message_length_limit] +max_chars = 2048 +note = "text content ≤ 2048 bytes (超过将截断 — server truncates; ~680 CJK chars at 3 bytes each). Counting unit is UTF-8 bytes, not chars. Chunking required for long replies." +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[capability.dm_support] +supported = true +note = "Yes — the self-built app model is 1:1 (app ↔ member via touser)." +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[capability.group_model] +kinds = ["user"] +note = "Self-built app has no group callback. A separate appchat (群聊) send API and the 智能机器人 model exist but are not used by this adapter." +source = "https://developer.work.weixin.qq.com/document/path/90248" + +[capability.group_sender_identity] +stable_id = "yes" +note = "n/a for group in this adapter (1:1 only). In 1:1, the stable sender id is FromUserName (member UserID), always present, no consent gate." +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[capability.send_model] +model = "push_only" +reply_token_ttl_sec = 0 +max_objects_per_send = 0 +note = "Push — app calls message/send with access_token + agentid + touser. No reply-window / reply-token; a user need not have messaged first." +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Allowed and unsolicited. Quotas: per app ≤ (account cap × 200) person-times/day; per app→member ≤ 30/min and 1000/hour (excess 会被丢弃不下发 — silently dropped)." +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[capability.bot_to_bot] +delivered = false +note = "n/a — self-built app callbacks originate from human members (FromUserName UserID); the platform does not deliver other apps'/bots' messages to a self-built app." +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[capability.typing_indicator] +supported = false +note = "Not supported by the API." +source = "https://developer.work.weixin.qq.com/document/path/90236" + + +# ═══ Schema 2 — openab-feature-support (source of truth: our code + PR) ══════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "message/send msgtype=text with agentid + touser; token cached & auto-refreshed, retries once on errcode 42001." +source = ["crates/openab-gateway/src/adapters/wecom.rs#send_text", "crates/openab-gateway/src/adapters/wecom.rs#post_with_token_retry"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "implemented" +note = "Byte-aware split at 2048 (WeCom's server-side byte cap); prefers \\n boundaries, splits over-long lines at UTF-8 char boundaries (char_indices) so multibyte chars aren't severed. Uses local split_text_lines, not the trait split_delivery." +source = ["crates/openab-gateway/src/adapters/wecom.rs#split_text_lines", "crates/openab-core/src/adapter.rs#split_delivery"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "workaround" +note = "No edit API in callback mode, so streaming = optional ⏳... placeholder + debounce-buffer chunks into a watch channel + recall placeholder + resend consolidated text (flush_thinking). Causes client flicker → default OFF (WECOM_STREAMING_ENABLED, debounce_secs=3). With streaming off, chunks buffer silently and one consolidated message is sent." +source = ["crates/openab-gateway/src/adapters/wecom.rs#flush_thinking"] +pr = "" + +[[openab_features]] +feature = "reply_quote" +status = "n_a" +note = "No reply/quote primitive in the self-built app model. The trait send_message_with_reply default just falls back to plain send; adapter never wires it." +source = ["crates/openab-core/src/adapter.rs#send_message_with_reply"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "workaround" +note = "reply.command == edit_message only pushes new text into an in-flight streaming watch channel (handle_edit_message); there is no real WeCom edit. Outside a pending stream it is a no-op. The trait edit_message default (returns not supported) otherwise applies." +source = ["crates/openab-gateway/src/adapters/wecom.rs#handle_edit_message", "crates/openab-core/src/adapter.rs#edit_message"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "not_implemented" +note = "Recall API exists (24h window) but adapter never calls /cgi-bin/message/recall for user-facing delete — only internally to remove the thinking placeholder. Trait delete_message default edits to zero-width space, which WeCom can't honor." +source = ["crates/openab-gateway/src/adapters/wecom.rs#flush_thinking", "crates/openab-core/src/adapter.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "n_a" +note = "reply.command add_reaction / remove_reaction are explicitly matched and ignored with a log line — WeCom self-built apps have no reaction API." +source = ["crates/openab-gateway/src/adapters/wecom.rs"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "n_a" +note = "create_topic command explicitly ignored (logged). No thread primitive on platform." +source = ["crates/openab-gateway/src/adapters/wecom.rs"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "partial" +note = "Inbound image → download over HTTPS-only (SSRF guard), reject >10MB, resize ≤1200px + JPEG q75 (GIF passthrough). file → media/get (retry on 42001), reject >20MB, text files only (extension/filename allowlist) and must be valid UTF-8; binary/office files rejected. voice/video/location/link msgtypes are dropped (only text/image/file forwarded)." +source = ["crates/openab-gateway/src/adapters/wecom.rs#download_wecom_image", "crates/openab-gateway/src/adapters/wecom.rs#fetch_media_with_retry", "crates/openab-gateway/src/adapters/wecom.rs#download_wecom_file", "crates/openab-gateway/src/adapters/wecom.rs#is_text_file", "crates/openab-gateway/src/adapters/wecom.rs#resize_and_compress"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "not_implemented" +note = "voice msgtype is not in the accepted set (text|image|file); voice callbacks are dropped, no STT." +source = ["crates/openab-gateway/src/adapters/wecom.rs"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "implemented" +note = "Shared. Gateway ingress gate gate_incoming (L2 scope + L3 identity) runs before dispatch; wecom events carry sender.id = FromUserName (UserID) and channel.id = wecom:{corp_id}:{from_user} for keying." +source = ["crates/openab-gateway/src/adapters/wecom.rs", "crates/openab-core/src/gateway.rs", "crates/openab-core/src/adapter.rs#gate_incoming"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "implemented" +note = "Shared. On DenyIdentity the gateway echoes the sender their UserID with a request-access hint (throttled via echo_allowed); DenyScope silently drops. Platform-agnostic path, applies to wecom replies." +source = ["crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "n_a" +note = "Shared @mention gating only fires for group/supergroup channel_type; wecom events are channel_type=direct, so gating is bypassed by design." +source = ["crates/openab-gateway/src/adapters/wecom.rs", "crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "partial" +note = "Shared. No platform slash mechanism; commands arrive as plain text and are handled by OpenAB's generic command layer, not in the wecom adapter." +source = ["crates/openab-gateway/src/adapters/wecom.rs"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "n_a" +note = "Self-built app 1:1 callbacks come only from human members (is_bot: false); no other-bot delivery, no multi-bot channel." +source = ["crates/openab-gateway/src/adapters/wecom.rs"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "partial" +note = "Sessions keyed by wecom:{corp_id}:{from_user} (per-user 1:1); no group routing since there are no group callbacks in this model." +source = ["crates/openab-gateway/src/adapters/wecom.rs"] +pr = "" + +[[openab_features]] +feature = "cron_dispatch" +status = "not_implemented" +note = "Not wired for scheduled cron dispatch: `VALID_PLATFORMS` (cron.rs) covers only discord/slack/telegram and no adapter is registered for this platform in `cron_adapters`, so `cronjob.toml` jobs targeting it are rejected at startup by `validate_cronjobs`." +source = [] +pr = "" + + +# ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ + +[[quirks]] +date = "2026-07-04" +title = "Send / push model (no reply window)" +note = "WeCom self-built apps are pure push: given a valid access_token + agentid, the app can message any member via touser at any time — there is no LINE-style reply token or reply window. access_token (7200s TTL) is cached with a 300s refresh margin (TOKEN_REFRESH_MARGIN_SECS) and force-refreshed on errcode 42001 across both message/send and media/get." +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[[quirks]] +date = "2026-07-04" +title = "Crypto / callback specifics" +note = """ +EncodingAESKey is 43 base64 chars without padding; adapter appends = and decodes with Indifferent padding + allow_trailing_bits (the 43rd char's last 2 bits are not payload). Result must be exactly 32 bytes (decode_aes_key). +WeCom uses PKCS7 with block_size=32 (not 16); adapter decrypts AES-256-CBC with NoPadding and strips padding manually (pad value 1–32). Plaintext = random(16) + msg_len(4 BE) + msg + corp_id; inner corp_id must equal configured CORP_ID. IV = first 16 key bytes. +Defense-in-depth: outer envelope ToUserName must equal CORP_ID; msg_signature compared in constant time (subtle::ConstantTimeEq); stale callbacks (>300s timestamp skew) rejected; 30s TTL / 10k-entry dedupe cache on MsgId absorbs WeCom's ~5s retries. +gettoken requires corpsecret as a query param (protocol-mandated) — operators must redact query strings on /cgi-bin/gettoken in proxy logs; gateway never logs that URL. +""" +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/90238" + +[[quirks]] +date = "2026-07-04" +title = "\"Streaming\" is recall + resend" +note = "Because callback mode has no message-edit API, streaming is emulated: optional ⏳... placeholder, debounce-buffer deltas into a tokio::sync::watch channel (default 3s), then recall the placeholder and send the consolidated final text via flush_thinking. This flickers, so it is off by default (WECOM_STREAMING_ENABLED). With it off, deltas are buffered silently and one message is sent when the debounce settles — no flicker, no recall. A 300s idle cap on the debounce task prevents an orphaned pending entry." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/wecom.rs#flush_thinking" + +[[quirks]] +date = "2026-07-04" +title = "Inbound filtering is aggressive" +note = "Only text, image, file msgtypes are forwarded; voice / video / location / link are dropped. Files must pass a text-extension/filename allowlist AND be valid UTF-8 — office/binary files are rejected (no doc parsing). Images are HTTPS-only, ≤10MB, downscaled to ≤1200px JPEG q75 (GIF passthrough). Placeholder prompts are injected for media: image → \"Describe this image.\", file → \"User sent a file: {name}\"." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/wecom.rs#is_text_file" + +[[quirks]] +date = "2026-07-04" +title = "Inbound types: six enumerated, file delivered anyway" +note = "Self-built app receive doc enumerates six inbound types: text/image/voice/video/location/link — file is not listed there (yet delivered & handled by the adapter). FromUserName = member UserID, MsgId = 64-bit; no group/@mention/reaction/edit in this model." +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[[quirks]] +date = "2026-07-04" +title = "Text cap and push quotas" +note = "text content capped at 2048 bytes (truncated); push quota per app→member 30/min, 1000/hr, per-app (account-cap×200)/day, excess silently dropped. msgtypes: text/image/voice/video/file/textcard/news/mpnews/markdown/miniprogram_notice/template_card." +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[[quirks]] +date = "2026-07-04" +title = "No edit API; recall only" +note = "No edit API; only recall (/cgi-bin/message/recall) within 24h on this app's own messages (delete, not edit; WeChat-plugin-end data can't be pulled back)." +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/94867" + +[[quirks]] +date = "2026-07-04" +title = "Media caps" +note = "Media caps: image 10MB (JPG/PNG), voice 2MB/60s (AMR), video 10MB (MP4), file 20MB; min 5 bytes; temp media_id valid 3 days." +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/90253" + +[[quirks]] +date = "2026-07-04" +title = "Callback auth summary" +note = "Callback auth = SHA1 msg_signature (sorted token/ts/nonce/encrypt) + AES-256-CBC via 43-char EncodingAESKey, PKCS7 block_size=32, IV = first 16 key bytes." +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/90238" + +[[quirks]] +date = "2026-07-04" +title = "Section-2 refs verified; WecomAdapter is a standalone handler" +note = "All section-2 refs verified against the tree; WecomAdapter is a standalone handler (does not impl ChatAdapter); trait defaults referenced (split_delivery, send_message_with_reply, edit_message, delete_message, gate_incoming) live in openab-core/src/adapter.rs." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/wecom.rs" +refs = []