Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,63 @@ Every provider an agent triggers on must also appear in `persona.integrations`
GitHub event, Linear issue, Slack mention, Notion update, or Jira event arrives.
See [`examples/review-agent`](./examples/review-agent/) for a complete example.

### Manual payloads and reaction-approved actions

Cloud delivers an authenticated manual trigger as a `cron.tick`, just like a
schedule. Use the runtime discriminator to preserve the difference and surface
accepted-but-malformed trigger bodies instead of silently treating them as
clock ticks:

```ts
import { readAppTriggerIntent } from '@agentworkforce/runtime';

const intent = await readAppTriggerIntent(event);
if (intent.kind === 'malformed-app-trigger') {
throw new Error(`Invalid app trigger: ${intent.reason}`);
}
if (intent.kind === 'app-trigger') {
await handleManualPayload(intent.payload);
} else {
await handleSchedule();
}
```

For an action the user approves by reacting to a Slack card,
`@agentworkforce/delivery` owns the provider mechanics: normalized reaction
parsing, bounded hidden action identifiers, metadata-redaction recovery, and
exact actor/emoji/message binding. The agent still owns the domain action:

```ts
import {
buildSlackApprovalCard,
matchSlackApprovalReaction,
readSlackReaction
} from '@agentworkforce/delivery';

const card = buildSlackApprovalCard({
namespace: 'inbox.archive',
approverId: ownerSlackId,
actionIds: threadIds,
text: 'React :white_check_mark: to archive these conversations.',
validateActionId: isValidThreadId
});
// Post card.text + card.metadata + card.blocks through the authenticated Slack
// integration and retain the returned message timestamp.

const reaction = readSlackReaction((await event.expand('full')).data);
if (reaction) {
// Fetch exactly reaction.channel + reaction.messageTs with
// include_all_metadata: true before matching.
const approval = matchSlackApprovalReaction(reaction, reactedMessage, {
namespace: 'inbox.archive',
approverId: ownerSlackId,
appId: slackAppId,
validateActionId: isValidThreadId
});
if (approval) await archiveThreads(approval.actionIds);
}
```

## Run modes

`workforce deploy <persona-path>` defaults to the best available runner mode.
Expand Down
6 changes: 6 additions & 0 deletions packages/delivery/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add normalized Slack reaction parsing and reusable approval-card utilities
that bind bounded hidden action ids to an exact app-authored message,
approver, and emoji.

## [4.1.41] - 2026-08-14

### Released
Expand Down
13 changes: 13 additions & 0 deletions packages/delivery/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,30 @@ export {
linkSlackMentions,
loadSlackUsers,
readSlackMessage,
readSlackReaction,
requireSlackReceipt,
resolveSlackUserId,
slackSkipReason,
stripSlackLeadingMention,
type SlackInboundMessage,
type SlackReaction,
type SlackMentionIndex,
type SlackUser,
type SlackUsersOptions,
type SlackUsersWarning
} from './slack.js';

export {
buildSlackApprovalCard,
matchSlackApprovalReaction,
readSlackApproval,
type MatchSlackApprovalOptions,
type ReadSlackApprovalOptions,
type SlackApproval,
type SlackApprovalCard,
type SlackApprovalCardOptions
} from './slack-approval.js';

export { input, list, withTimeout, fetchWithTimeout } from './helpers.js';

export {
Expand Down
231 changes: 231 additions & 0 deletions packages/delivery/src/slack-approval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildSlackApprovalCard,
matchSlackApprovalReaction,
readSlackApproval
} from './slack-approval.js';
import type { SlackReaction } from './slack.js';

const options = {
namespace: 'github-inbox.archive',
approverId: 'U12345678'
} as const;
const matchOptions = { ...options, appId: 'A12345678' } as const;

test('buildSlackApprovalCard keeps exact ids out of rendered text and decodes them', () => {
const card = buildSlackApprovalCard({
...options,
text: 'React :white_check_mark: to archive these conversations.',
actionIds: ['thread:one', 'thread,two', 'thread:one'],
context: { batch: 2 }
});

const renderedText = card.blocks
.filter((block) => block.type === 'section')
.map((block) => JSON.stringify(block.text))
.join('\n');
assert.doesNotMatch(renderedText, /thread:one|thread,two/);
assert.deepEqual(
readSlackApproval({ ts: '1787300000.000100', ...card }, options),
{
namespace: 'github-inbox.archive',
approverId: 'U12345678',
actionIds: ['thread:one', 'thread,two'],
context: { batch: 2 }
}
);
});

test('readSlackApproval recovers from Slack-redacted metadata through hidden block ids', () => {
const card = buildSlackApprovalCard({
...options,
text: 'Approve the batch.',
actionIds: ['thread-1', 'thread-2']
});

assert.deepEqual(
readSlackApproval({
ts: '1787300000.000100',
metadata: {
event_type: card.metadata.event_type,
event_payload: {}
},
blocks: card.blocks
}, options),
{
namespace: 'github-inbox.archive',
approverId: 'U12345678',
actionIds: ['thread-1', 'thread-2']
}
);
assert.deepEqual(
readSlackApproval({ ts: '1787300000.000100', metadata: card.metadata }, options),
{
namespace: 'github-inbox.archive',
approverId: 'U12345678',
actionIds: ['thread-1', 'thread-2']
}
);
assert.equal(
readSlackApproval({ ts: '1787300000.000100', blocks: card.blocks }, options),
null
);
assert.equal(
readSlackApproval({
ts: '1787300000.000100',
metadata: { event_type: 'another_event', event_payload: {} },
blocks: card.blocks
}, options),
null
);
});

test('matchSlackApprovalReaction binds actor, emoji, and exact message timestamp', () => {
const card = buildSlackApprovalCard({
...options,
text: 'Approve the batch.',
actionIds: ['thread-1']
});
const reaction: SlackReaction = {
action: 'added',
channel: 'D12345678',
messageTs: '1787300000.000100',
actorId: 'U12345678',
emoji: 'white_check_mark'
};
const message = { ts: reaction.messageTs, app_id: matchOptions.appId, ...card };

assert.deepEqual(
matchSlackApprovalReaction(reaction, message, matchOptions)?.actionIds,
['thread-1']
);
assert.deepEqual(
matchSlackApprovalReaction(
reaction,
{ ...message, app_id: undefined, bot_profile: { app_id: matchOptions.appId } },
matchOptions
)?.actionIds,
['thread-1']
);
assert.equal(
matchSlackApprovalReaction({ ...reaction, actorId: 'U87654321' }, message, matchOptions),
null
);
assert.equal(
matchSlackApprovalReaction({ ...reaction, emoji: 'eyes' }, message, matchOptions),
null
);
assert.equal(
matchSlackApprovalReaction(
reaction,
{ ...message, ts: '1787300002.000300' },
matchOptions
),
null
);
assert.equal(
matchSlackApprovalReaction({ ...reaction, action: 'removed' }, message, matchOptions),
null
);
assert.equal(
matchSlackApprovalReaction(
reaction,
{ ...message, app_id: 'A87654321' },
matchOptions
),
null
);
});

test('Slack approval cards enforce domain validators and fail closed on tampering', () => {
const validateActionId = (id: string) => /^thread-[0-9]+$/.test(id);
assert.throws(
() => buildSlackApprovalCard({
...options,
text: 'Approve.',
actionIds: ['message-1'],
validateActionId
}),
/Invalid Slack approval action id/
);

const card = buildSlackApprovalCard({
...options,
text: 'Approve.',
actionIds: ['thread-1'],
validateActionId
});
const block = card.blocks.find((value) => typeof value.block_id === 'string');
assert.ok(block);
assert.equal(
readSlackApproval({
metadata: { event_type: card.metadata.event_type, event_payload: {} },
blocks: [{ ...block, block_id: `${String(block.block_id).replace(/thread-1$/, '')}%E0%A4%A` }]
}, { ...options, validateActionId }),
null
);
assert.equal(
readSlackApproval({ ...card }, { ...options, approverId: 'U87654321' }),
null
);

const inconsistent = {
...card,
metadata: {
...card.metadata,
event_payload: {
...card.metadata.event_payload,
action_ids: ['thread-2']
}
}
};
assert.equal(
readSlackApproval(inconsistent, { ...options, validateActionId }),
null
);
});

test('Slack approval cards enforce metadata, block-id, and section limits', () => {
assert.throws(
() => buildSlackApprovalCard({
...options,
text: 'Approve.',
actionIds: ['thread-1'],
context: { note: 'x'.repeat(4_000) }
}),
/event payload is too large/
);
assert.throws(
() => buildSlackApprovalCard({
...options,
text: 'Approve.',
actionIds: ['x'.repeat(300)]
}),
/too large for a block id/
);

const card = buildSlackApprovalCard({
...options,
text: 'x'.repeat(6_100),
actionIds: ['thread-1']
});
const sections = card.blocks.filter((block) => block.type === 'section');
assert.equal(sections.length, 3);
for (const section of sections) {
const text = section.text as { text: string };
assert.ok(text.text.length <= 2_900);
}

const emojiText = '🙂'.repeat(3_000);
const emojiCard = buildSlackApprovalCard({
...options,
text: emojiText,
actionIds: ['thread-1']
});
const emojiSections = emojiCard.blocks
.filter((block) => block.type === 'section')
.map((block) => (block.text as { text: string }).text);
assert.equal(emojiSections.join(''), emojiText);
assert.ok(emojiSections.every((text) => Array.from(text).length <= 2_900));
});
Loading
Loading