switchroom 0.15.7 → 0.15.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +189 -7
- package/dist/auth-broker/index.js +18 -0
- package/dist/cli/notion-write-pretool.mjs +18 -0
- package/dist/cli/switchroom.js +204 -30
- package/dist/host-control/main.js +18 -0
- package/dist/vault/approvals/kernel-server.js +19 -1
- package/dist/vault/broker/server.js +19 -1
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +24 -5
- package/telegram-plugin/dist/gateway/gateway.js +170 -8
- package/telegram-plugin/gateway/gateway.ts +136 -2
- package/telegram-plugin/gateway/reaction-dispatch.ts +174 -0
- package/telegram-plugin/tests/reaction-dispatch.test.ts +137 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaction-dispatch: event-driven Telegram `message_reaction` → agent turn.
|
|
3
|
+
*
|
|
4
|
+
* Issue: https://github.com/switchroom/switchroom/issues/2291
|
|
5
|
+
*
|
|
6
|
+
* Distinct from the `reactions` trigger module (`reaction-trigger.ts`,
|
|
7
|
+
* #1074), which forwards reactions on the BOT's own messages as feedback
|
|
8
|
+
* (debounced, hour-capped, default ON, bot-authored-gated). This module
|
|
9
|
+
* implements the #2291 request: deliver ANY qualifying `message_reaction`
|
|
10
|
+
* update — regardless of who authored the reacted message — as an inbound
|
|
11
|
+
* channel turn shaped like a button-callback event:
|
|
12
|
+
*
|
|
13
|
+
* <channel source="switchroom-telegram" event="reaction"
|
|
14
|
+
* emoji="👨💻" message_id="123" chat_id="456" user="Ken">
|
|
15
|
+
* <reacted message text>
|
|
16
|
+
* </channel>
|
|
17
|
+
*
|
|
18
|
+
* It exists so reaction-driven workflows (e.g. clerk's Telegram→Linear
|
|
19
|
+
* capture on a 👨💻 reaction) stop polling get_recent_messages on a cron.
|
|
20
|
+
*
|
|
21
|
+
* Design:
|
|
22
|
+
* - Default OFF. No turns fire unless an operator configures a
|
|
23
|
+
* `reaction_dispatch` block with a non-empty `emojis` allowlist.
|
|
24
|
+
* - Emoji allowlist filter. Only additions/changes whose new emoji is
|
|
25
|
+
* in the allowlist dispatch. Removals never fire (handled by the
|
|
26
|
+
* gateway before this module is consulted).
|
|
27
|
+
* - No bot-authored gate — the whole point is reacting to arbitrary
|
|
28
|
+
* messages (the user's own, peers', or the bot's).
|
|
29
|
+
* - The reacted message's text is looked up from the SQLite history
|
|
30
|
+
* buffer; a miss degrades to an empty body (the envelope still
|
|
31
|
+
* carries emoji / message_id / chat_id / user).
|
|
32
|
+
*
|
|
33
|
+
* This module is pure gateway-internal logic — no Telegram API calls,
|
|
34
|
+
* no IPC. The gateway's `message_reaction` handler calls
|
|
35
|
+
* `evaluateReactionDispatch` then `buildReactionDispatchInbound`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export interface ReactionDispatchResolvedConfig {
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
/** Allowlist of emoji that trigger a dispatch. Empty ⇒ nothing fires. */
|
|
41
|
+
emojis: ReadonlySet<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Built-in defaults — applied when the cascade does not set a field.
|
|
46
|
+
* Default OFF: no `reaction_dispatch` block ⇒ no reaction turns. This is
|
|
47
|
+
* the noise-avoidance contract from #2291.
|
|
48
|
+
*/
|
|
49
|
+
export const REACTION_DISPATCH_DEFAULTS: ReactionDispatchResolvedConfig = Object.freeze({
|
|
50
|
+
enabled: false,
|
|
51
|
+
emojis: Object.freeze(new Set<string>()) as ReadonlySet<string>,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Cascade-resolved `reaction_dispatch` slice as it appears on the agent
|
|
56
|
+
* config. Shape mirrors `ReactionDispatchSchema` in `src/config/schema.ts`.
|
|
57
|
+
* Typed loosely so this module stays independent of the src/ zod schemas.
|
|
58
|
+
*/
|
|
59
|
+
export interface ReactionDispatchConfigInput {
|
|
60
|
+
enabled?: boolean;
|
|
61
|
+
emojis?: readonly string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fold a raw cascade-resolved `reaction_dispatch:` block into the runtime
|
|
66
|
+
* shape, filling defaults for missing fields. A `null`/`undefined` raw
|
|
67
|
+
* input collapses to the (OFF) built-in defaults.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveReactionDispatchConfig(
|
|
70
|
+
raw: ReactionDispatchConfigInput | null | undefined,
|
|
71
|
+
): ReactionDispatchResolvedConfig {
|
|
72
|
+
if (!raw) return REACTION_DISPATCH_DEFAULTS;
|
|
73
|
+
return {
|
|
74
|
+
enabled: raw.enabled ?? REACTION_DISPATCH_DEFAULTS.enabled,
|
|
75
|
+
emojis: raw.emojis !== undefined
|
|
76
|
+
? new Set(raw.emojis)
|
|
77
|
+
: REACTION_DISPATCH_DEFAULTS.emojis,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Predicate ───────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export interface ReactionDispatchCandidate {
|
|
84
|
+
/** Emoji string from the new_reaction; null when not a plain emoji. */
|
|
85
|
+
emoji: string | null;
|
|
86
|
+
/** 'add' | 'change' — 'remove' candidates are rejected pre-call. */
|
|
87
|
+
action: 'add' | 'change';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type ReactionDispatchDecision =
|
|
91
|
+
| { ok: true }
|
|
92
|
+
| { ok: false; reason: 'disabled' | 'no_emoji' | 'emoji_not_in_allowlist' };
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Synchronous predicate. Returns `ok: true` only when the dispatch path
|
|
96
|
+
* is enabled and the candidate's emoji is in the allowlist. Removals are
|
|
97
|
+
* the caller's responsibility to filter (the Bot API distinguishes them
|
|
98
|
+
* via empty `new_reaction`); this predicate only ever sees add/change.
|
|
99
|
+
*/
|
|
100
|
+
export function evaluateReactionDispatch(
|
|
101
|
+
cfg: ReactionDispatchResolvedConfig,
|
|
102
|
+
c: ReactionDispatchCandidate,
|
|
103
|
+
): ReactionDispatchDecision {
|
|
104
|
+
if (!cfg.enabled) return { ok: false, reason: 'disabled' };
|
|
105
|
+
if (c.emoji === null) return { ok: false, reason: 'no_emoji' };
|
|
106
|
+
if (!cfg.emojis.has(c.emoji)) return { ok: false, reason: 'emoji_not_in_allowlist' };
|
|
107
|
+
return { ok: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Inbound envelope builder ─────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export interface ReactionDispatchInput {
|
|
113
|
+
emoji: string;
|
|
114
|
+
chatId: string;
|
|
115
|
+
messageId: number;
|
|
116
|
+
/** Display name of the reacter (first_name → username → string id). */
|
|
117
|
+
user: string;
|
|
118
|
+
/** Numeric user_id of the reacter, for the wire's userId field. */
|
|
119
|
+
userId: number;
|
|
120
|
+
/** Reacted message's text from the history buffer; '' on miss. */
|
|
121
|
+
reactedText: string;
|
|
122
|
+
/** Forum thread id if the reacted message lived in a topic. */
|
|
123
|
+
threadId?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ReactionDispatchInbound {
|
|
127
|
+
text: string;
|
|
128
|
+
meta: Record<string, string>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the synthesized inbound's `text` + `meta`. The envelope mirrors
|
|
133
|
+
* the button-callback event shape so the agent reads reactions and button
|
|
134
|
+
* taps the same way. The reacted message's text lands in the element body.
|
|
135
|
+
*/
|
|
136
|
+
export function buildReactionDispatchInbound(
|
|
137
|
+
input: ReactionDispatchInput,
|
|
138
|
+
): ReactionDispatchInbound {
|
|
139
|
+
const safeEmoji = escapeAttr(input.emoji);
|
|
140
|
+
const safeUser = escapeAttr(input.user);
|
|
141
|
+
const safeChat = escapeAttr(input.chatId);
|
|
142
|
+
const body = escapeBody(input.reactedText);
|
|
143
|
+
|
|
144
|
+
const text =
|
|
145
|
+
`<channel source="switchroom-telegram" event="reaction" ` +
|
|
146
|
+
`emoji="${safeEmoji}" message_id="${input.messageId}" ` +
|
|
147
|
+
`chat_id="${safeChat}" user="${safeUser}">` +
|
|
148
|
+
body +
|
|
149
|
+
`</channel>`;
|
|
150
|
+
|
|
151
|
+
const meta: Record<string, string> = {
|
|
152
|
+
source: 'switchroom-telegram',
|
|
153
|
+
event: 'reaction',
|
|
154
|
+
reaction_emoji: input.emoji,
|
|
155
|
+
target_message_id: String(input.messageId),
|
|
156
|
+
reacted_text: input.reactedText,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return { text, meta };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Minimal XML-attr escape; the body uses a looser escape because it lands
|
|
163
|
+
// inside the element body, not an attribute value.
|
|
164
|
+
function escapeAttr(s: string): string {
|
|
165
|
+
return s
|
|
166
|
+
.replace(/&/g, '&')
|
|
167
|
+
.replace(/"/g, '"')
|
|
168
|
+
.replace(/</g, '<')
|
|
169
|
+
.replace(/>/g, '>');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function escapeBody(s: string): string {
|
|
173
|
+
return s.replace(/</g, '<').replace(/>/g, '>');
|
|
174
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the reaction-dispatch primitives (#2291).
|
|
3
|
+
*
|
|
4
|
+
* Covers config resolution (default OFF), the synchronous emoji-allowlist
|
|
5
|
+
* predicate (match / no-match / removed-ignored / no-emoji), and the
|
|
6
|
+
* inbound envelope builder (shape + history-hit / history-miss body).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'bun:test'
|
|
10
|
+
import {
|
|
11
|
+
REACTION_DISPATCH_DEFAULTS,
|
|
12
|
+
resolveReactionDispatchConfig,
|
|
13
|
+
evaluateReactionDispatch,
|
|
14
|
+
buildReactionDispatchInbound,
|
|
15
|
+
type ReactionDispatchResolvedConfig,
|
|
16
|
+
} from '../gateway/reaction-dispatch.ts'
|
|
17
|
+
|
|
18
|
+
function cfg(over: Partial<{ enabled: boolean; emojis: string[] }> = {}): ReactionDispatchResolvedConfig {
|
|
19
|
+
return resolveReactionDispatchConfig({
|
|
20
|
+
enabled: over.enabled ?? true,
|
|
21
|
+
emojis: over.emojis ?? ['👨💻'],
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('resolveReactionDispatchConfig — default OFF', () => {
|
|
26
|
+
it('null/undefined raw collapses to the OFF defaults', () => {
|
|
27
|
+
expect(resolveReactionDispatchConfig(undefined)).toBe(REACTION_DISPATCH_DEFAULTS)
|
|
28
|
+
expect(resolveReactionDispatchConfig(null)).toBe(REACTION_DISPATCH_DEFAULTS)
|
|
29
|
+
expect(REACTION_DISPATCH_DEFAULTS.enabled).toBe(false)
|
|
30
|
+
expect(REACTION_DISPATCH_DEFAULTS.emojis.size).toBe(0)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('a block with emojis but no enabled flag stays disabled by default', () => {
|
|
34
|
+
const r = resolveReactionDispatchConfig({ emojis: ['👨💻'] })
|
|
35
|
+
// enabled defaults to false even when an allowlist is provided.
|
|
36
|
+
expect(r.enabled).toBe(false)
|
|
37
|
+
expect(r.emojis.has('👨💻')).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('emojis REPLACE (set membership), enabled overrides', () => {
|
|
41
|
+
const r = resolveReactionDispatchConfig({ enabled: true, emojis: ['✅', '❌'] })
|
|
42
|
+
expect(r.enabled).toBe(true)
|
|
43
|
+
expect([...r.emojis].sort()).toEqual(['✅', '❌'].sort())
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('evaluateReactionDispatch — emoji filtering', () => {
|
|
48
|
+
it('allowlist match → ok', () => {
|
|
49
|
+
expect(evaluateReactionDispatch(cfg(), { emoji: '👨💻', action: 'add' })).toEqual({ ok: true })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('allowlist no-match → rejected', () => {
|
|
53
|
+
expect(evaluateReactionDispatch(cfg(), { emoji: '👍', action: 'add' })).toEqual({
|
|
54
|
+
ok: false,
|
|
55
|
+
reason: 'emoji_not_in_allowlist',
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('disabled config → rejected even on an allowlisted emoji', () => {
|
|
60
|
+
expect(
|
|
61
|
+
evaluateReactionDispatch(cfg({ enabled: false }), { emoji: '👨💻', action: 'add' }),
|
|
62
|
+
).toEqual({ ok: false, reason: 'disabled' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('default-off config never fires', () => {
|
|
66
|
+
expect(
|
|
67
|
+
evaluateReactionDispatch(REACTION_DISPATCH_DEFAULTS, { emoji: '👨💻', action: 'add' }),
|
|
68
|
+
).toEqual({ ok: false, reason: 'disabled' })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('null emoji (custom emoji) → rejected', () => {
|
|
72
|
+
expect(evaluateReactionDispatch(cfg(), { emoji: null, action: 'add' })).toEqual({
|
|
73
|
+
ok: false,
|
|
74
|
+
reason: 'no_emoji',
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('change action with allowlisted emoji → ok (add/change both dispatch)', () => {
|
|
79
|
+
expect(evaluateReactionDispatch(cfg(), { emoji: '👨💻', action: 'change' })).toEqual({
|
|
80
|
+
ok: true,
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('buildReactionDispatchInbound — envelope shape', () => {
|
|
86
|
+
it('history hit: body carries the reacted message text', () => {
|
|
87
|
+
const { text, meta } = buildReactionDispatchInbound({
|
|
88
|
+
emoji: '👨💻',
|
|
89
|
+
chatId: '456',
|
|
90
|
+
messageId: 123,
|
|
91
|
+
user: 'Ken',
|
|
92
|
+
userId: 999,
|
|
93
|
+
reactedText: 'capture this to Linear',
|
|
94
|
+
})
|
|
95
|
+
expect(text).toBe(
|
|
96
|
+
'<channel source="switchroom-telegram" event="reaction" ' +
|
|
97
|
+
'emoji="👨💻" message_id="123" chat_id="456" user="Ken">' +
|
|
98
|
+
'capture this to Linear</channel>',
|
|
99
|
+
)
|
|
100
|
+
expect(meta).toMatchObject({
|
|
101
|
+
source: 'switchroom-telegram',
|
|
102
|
+
event: 'reaction',
|
|
103
|
+
reaction_emoji: '👨💻',
|
|
104
|
+
target_message_id: '123',
|
|
105
|
+
reacted_text: 'capture this to Linear',
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('history miss: empty body, envelope still well-formed', () => {
|
|
110
|
+
const { text, meta } = buildReactionDispatchInbound({
|
|
111
|
+
emoji: '✅',
|
|
112
|
+
chatId: '456',
|
|
113
|
+
messageId: 7,
|
|
114
|
+
user: 'Ken',
|
|
115
|
+
userId: 999,
|
|
116
|
+
reactedText: '',
|
|
117
|
+
})
|
|
118
|
+
expect(text).toBe(
|
|
119
|
+
'<channel source="switchroom-telegram" event="reaction" ' +
|
|
120
|
+
'emoji="✅" message_id="7" chat_id="456" user="Ken"></channel>',
|
|
121
|
+
)
|
|
122
|
+
expect(meta.reacted_text).toBe('')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('escapes XML-significant chars in attrs and body', () => {
|
|
126
|
+
const { text } = buildReactionDispatchInbound({
|
|
127
|
+
emoji: '👨💻',
|
|
128
|
+
chatId: '456',
|
|
129
|
+
messageId: 1,
|
|
130
|
+
user: 'A & B "x"',
|
|
131
|
+
userId: 1,
|
|
132
|
+
reactedText: 'a <b> & c',
|
|
133
|
+
})
|
|
134
|
+
expect(text).toContain('user="A & B "x""')
|
|
135
|
+
expect(text).toContain('>a <b> & c<')
|
|
136
|
+
})
|
|
137
|
+
})
|