Skip to content

Commit a9f6c86

Browse files
committed
Synchronize portable ACP agent portraits to Slack app icons
1 parent 3040109 commit a9f6c86

11 files changed

Lines changed: 835 additions & 10 deletions

File tree

‎Cargo.lock‎

Lines changed: 141 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "acp-slack-adapter"
3-
version = "0.6.0"
3+
version = "0.7.0"
44
edition = "2024"
55
rust-version = "1.88"
66
publish = false
@@ -11,6 +11,7 @@ description = "Generic ACP integration with Slack conversations and presentation
1111
[dependencies]
1212
anyhow = "1"
1313
base64 = "0.22"
14+
image = { version = "0.25", default-features = false, features = ["png"] }
1415
axum = { version = "0.8.9", features = ["ws", "macros"] }
1516
axum-server = { version = "0.8.0", features = ["tls-rustls"] }
1617
clap = { version = "4", features = ["derive"] }
@@ -21,7 +22,7 @@ libc = "0.2"
2122
pam-client = { version = "0.5.0", default-features = false }
2223
rand = "0.8"
2324
rcgen = "0.14"
24-
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
25+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
2526
rusqlite = { version = "0.38", features = ["bundled"] }
2627
rustls = { version = "0.23", features = ["ring"] }
2728
rustls-pemfile = "2"

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,5 @@ or an already established session ID; tool-scoped questions require a known tool
249249
Validation uses independent ACP peers and a local Socket Mode service, including
250250
login, URL opening, typed answers, wrong-user/view rejection, invalid input and
251251
transcript isolation. Live Slack UI confirmation is a separate check.
252+
253+
Automatic Slack app portraits from compatible agents: [agent images](docs/agent-images.md).

‎docs/agent-images.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Keep an agent's Slack image in sync
2+
3+
Upload an image in the connected agent's identity settings. In this connector,
4+
open **Connection settings → Agent image → Connect image updates**. Supply a
5+
Slack app configuration access token and its refresh token, generated for the
6+
workspace under **Your App Configuration Tokens** at https://api.slack.com/apps.
7+
Use a separate token pair per connector: rotating a shared refresh token in two
8+
processes would invalidate the other process's copy. These credentials can
9+
manage your apps; they stay in this connector's private secret store and are
10+
never sent to the ACP agent or included in configuration exports or UI state.
11+
12+
The connector reads the optional `agent-identity/1` extension over its configured
13+
ACP connection every two minutes while running. It uploads changed image bytes
14+
using Slack's documented [`apps.icon.set`](https://docs.slack.dev/reference/methods/apps.icon.set/)
15+
API. This changes the actual app icon, including DMs and profile surfaces, rather
16+
than overriding only the appearance of individual messages. No public image
17+
hosting, `chat:write.customize`, transcript access or model prompt is needed.
18+
The app configuration token needs `app_configurations:write` and access to this
19+
app. Manager-app tokens can only change apps they created. Slack remains the
20+
authority on whether a token may change the selected app.
21+
22+
The bot identity independently verifies the destination app before an upload.
23+
Icons are deduplicated by checksum; accepted updates and retry deadlines survive
24+
restarts. A replacement is picked up automatically. Removing a previously synced
25+
portrait uploads the agent's neutral fallback. An agent without an uploaded
26+
portrait leaves a manually selected Slack icon alone. Disconnecting image access
27+
stops sync and leaves Slack's existing icon in place. Slack clients may briefly
28+
cache old icons after an accepted update.
29+
30+
Failures appear in image status and do not interrupt conversations. Rate limits
31+
respect `Retry-After`; uncertain responses may safely retry the idempotent icon
32+
replacement after a delay. Expiring configuration tokens rotate using
33+
[`tooling.tokens.rotate`](https://docs.slack.dev/reference/methods/tooling.tokens.rotate/)
34+
when a refresh token is configured. A lost/revoked token needs reconnecting.
35+
36+
## Generic ACP compatibility
37+
38+
Portable identity is optional, **not part of base ACP**. Any ACP implementation
39+
can implement the [documented extension](https://github.com/microsoft/amplifier-constellation-instance/blob/main/docs/agent-identity.md).
40+
The connector only calls the exact advertised method and accepts bounded,
41+
checksum-verified PNG bytes. It does not read the agent's installation files,
42+
use its management token, or fetch an agent-provided image URL. Agents without
43+
this extension keep their normal Slack icon and ordinary ACP behavior.
44+
45+
## Automation
46+
47+
- `GET /api/v1/identity`: synchronization status (`observe`).
48+
- `POST /api/v1/identity` with `{}`: sync now (`admin`), respecting retry deadlines.
49+
- `PUT /api/v1/identity/credential` with `{ "token": "...", "refresh_token": "..." }`:
50+
save private configuration access (`admin`). Optional `expires_at` is Unix seconds.
51+
- `DELETE /api/v1/identity/credential`: stop synchronization and remove local access.
52+
53+
The UI exposes `identity.open`, `identity.credentials`, `identity.sync` and
54+
`identity.disconnect` through `slackConnector.act`. Its view/modal state uses the
55+
existing authenticated view API. Credential input values are excluded from it.

‎src/driver.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,28 @@ pub async fn check(c: &Config, home: &Path) -> Result<Value> {
380380
Ok(json!({"ok":true,"protocol_version":1,"agent_info":init["agentInfo"],"load_session":init["agentCapabilities"]["loadSession"]==true,"authentication":crate::acp_auth::authentication(&init),"note":"ACP handshake and session creation succeeded. No prompt or Slack message was sent."}))
381381
}).await.context("ACP connection check timed out")?
382382
}
383+
/// Optional presentation extension. No session, model prompt, or remote URL fetch.
384+
pub async fn identity(c: &Config, home: &Path) -> Result<Option<Value>> {
385+
tokio::time::timeout(Duration::from_secs(30), async {
386+
let mut config = c.clone();
387+
config.acp.client = Default::default();
388+
let (mut client, init) = Client::connect(&config, home).await?;
389+
let capability = &init["_meta"]["com.microsoft.amplifier.identity"];
390+
if capability["schema"] != "agent-identity/1"
391+
|| capability["method"] != "_amplifier/identity/get"
392+
{
393+
return Ok(None);
394+
}
395+
Ok(Some(
396+
client
397+
.peer
398+
.request("_amplifier/identity/get", json!({}))
399+
.await?,
400+
))
401+
})
402+
.await
403+
.context("Agent identity check timed out")?
404+
}
383405
pub async fn decide(c: Config, home: PathBuf, input: Value) -> Result<Decision> {
384406
let timeout = c.acp.timeout_seconds;
385407
tokio::time::timeout(Duration::from_secs(timeout), turn(&c, &home, &input))

0 commit comments

Comments
 (0)