switchroom 0.15.6 → 0.15.8
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 +102 -5
- package/dist/auth-broker/index.js +18 -0
- package/dist/cli/notion-write-pretool.mjs +18 -0
- package/dist/cli/switchroom.js +124 -25
- 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 +171 -9
- 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
|
@@ -366,6 +366,7 @@ import type {
|
|
|
366
366
|
PermissionEvent,
|
|
367
367
|
} from './ipc-protocol.js'
|
|
368
368
|
import { DebounceBuffer, HourCap, buildReactionInboundMeta, buildReactionInboundText, evaluateTriggerCandidate, isGroupChat, resolveReactionsConfig, truncatePreview, type PendingReaction, type ReactionBatch, type ReactionsResolvedConfig } from './reaction-trigger.js'
|
|
369
|
+
import { buildReactionDispatchInbound, evaluateReactionDispatch, resolveReactionDispatchConfig, type ReactionDispatchResolvedConfig } from './reaction-dispatch.js'
|
|
369
370
|
import { writePidFile, clearPidFile } from './pid-file.js'
|
|
370
371
|
import { acquireStartupLock, releaseStartupLock } from './startup-mutex.js'
|
|
371
372
|
import { drainShutdown } from './shutdown-drain.js'
|
|
@@ -19825,6 +19826,120 @@ function getReactionDebounce(): DebounceBuffer {
|
|
|
19825
19826
|
return reactionDebounce
|
|
19826
19827
|
}
|
|
19827
19828
|
|
|
19829
|
+
// ─── reaction_dispatch (#2291) ─────────────────────────────────────────────
|
|
19830
|
+
// Event-driven dispatch of ANY qualifying message_reaction as an inbound
|
|
19831
|
+
// `<channel event="reaction">` turn. Default OFF; independent of the
|
|
19832
|
+
// bot-authored `reactions` feedback path above.
|
|
19833
|
+
let reactionDispatchCfg: ReactionDispatchResolvedConfig | null = null
|
|
19834
|
+
|
|
19835
|
+
function getReactionDispatchConfig(): ReactionDispatchResolvedConfig {
|
|
19836
|
+
if (reactionDispatchCfg) return reactionDispatchCfg
|
|
19837
|
+
let raw: unknown = undefined
|
|
19838
|
+
try {
|
|
19839
|
+
const cfg = loadSwitchroomConfig()
|
|
19840
|
+
const agentName = process.env.SWITCHROOM_AGENT_NAME
|
|
19841
|
+
if (agentName) {
|
|
19842
|
+
const rawAgent = cfg.agents?.[agentName]
|
|
19843
|
+
if (rawAgent) {
|
|
19844
|
+
const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
|
|
19845
|
+
raw = (resolved as { reaction_dispatch?: unknown }).reaction_dispatch
|
|
19846
|
+
}
|
|
19847
|
+
}
|
|
19848
|
+
} catch (err) {
|
|
19849
|
+
process.stderr.write(
|
|
19850
|
+
`telegram gateway: reaction_dispatch: config load failed, defaulting OFF: ${(err as Error).message}\n`,
|
|
19851
|
+
)
|
|
19852
|
+
}
|
|
19853
|
+
reactionDispatchCfg = resolveReactionDispatchConfig(
|
|
19854
|
+
raw as Parameters<typeof resolveReactionDispatchConfig>[0] ?? null,
|
|
19855
|
+
)
|
|
19856
|
+
return reactionDispatchCfg
|
|
19857
|
+
}
|
|
19858
|
+
|
|
19859
|
+
/**
|
|
19860
|
+
* Event-driven reaction → inbound turn (#2291). Called from the
|
|
19861
|
+
* message_reaction handler for add/change events. Filters by the
|
|
19862
|
+
* `reaction_dispatch` emoji allowlist (default empty ⇒ no-op), looks up
|
|
19863
|
+
* the reacted message's text from the SQLite history buffer (graceful on
|
|
19864
|
+
* miss), and injects an inbound shaped like a button-callback event via
|
|
19865
|
+
* the same ipcServer.sendToAgent path cron uses. Removals never reach
|
|
19866
|
+
* here (the caller filters them).
|
|
19867
|
+
*/
|
|
19868
|
+
function maybeDispatchReaction(args: {
|
|
19869
|
+
chatId: string
|
|
19870
|
+
messageId: number
|
|
19871
|
+
emoji: string | null
|
|
19872
|
+
action: 'add' | 'change'
|
|
19873
|
+
user: string
|
|
19874
|
+
userId: number
|
|
19875
|
+
threadId?: number
|
|
19876
|
+
}): void {
|
|
19877
|
+
const cfg = getReactionDispatchConfig()
|
|
19878
|
+
const decision = evaluateReactionDispatch(cfg, { emoji: args.emoji, action: args.action })
|
|
19879
|
+
if (!decision.ok) {
|
|
19880
|
+
if (decision.reason === 'emoji_not_in_allowlist' && cfg.enabled) {
|
|
19881
|
+
process.stderr.write(
|
|
19882
|
+
`telegram gateway: reaction_dispatch.reject reason=allowlist_miss emoji=${args.emoji} chat=${args.chatId}\n`,
|
|
19883
|
+
)
|
|
19884
|
+
}
|
|
19885
|
+
return
|
|
19886
|
+
}
|
|
19887
|
+
|
|
19888
|
+
const agentName = process.env.SWITCHROOM_AGENT_NAME
|
|
19889
|
+
if (!agentName) {
|
|
19890
|
+
process.stderr.write(
|
|
19891
|
+
`telegram gateway: reaction_dispatch: skipped — SWITCHROOM_AGENT_NAME unset\n`,
|
|
19892
|
+
)
|
|
19893
|
+
return
|
|
19894
|
+
}
|
|
19895
|
+
|
|
19896
|
+
// History lookup is best-effort; a miss yields an empty body. The
|
|
19897
|
+
// envelope still carries emoji / message_id / chat_id / user.
|
|
19898
|
+
let reactedText = ''
|
|
19899
|
+
if (HISTORY_ENABLED) {
|
|
19900
|
+
try {
|
|
19901
|
+
const row = lookupMessageRoleAndText(args.chatId, args.messageId)
|
|
19902
|
+
reactedText = row?.text ?? ''
|
|
19903
|
+
} catch (err) {
|
|
19904
|
+
process.stderr.write(
|
|
19905
|
+
`telegram gateway: reaction_dispatch: history lookup failed: ${err}\n`,
|
|
19906
|
+
)
|
|
19907
|
+
}
|
|
19908
|
+
}
|
|
19909
|
+
|
|
19910
|
+
const { text, meta } = buildReactionDispatchInbound({
|
|
19911
|
+
emoji: args.emoji!,
|
|
19912
|
+
chatId: args.chatId,
|
|
19913
|
+
messageId: args.messageId,
|
|
19914
|
+
user: args.user,
|
|
19915
|
+
userId: args.userId,
|
|
19916
|
+
reactedText,
|
|
19917
|
+
...(typeof args.threadId === 'number' ? { threadId: args.threadId } : {}),
|
|
19918
|
+
})
|
|
19919
|
+
|
|
19920
|
+
const ts = Date.now()
|
|
19921
|
+
const inbound: InboundMessage = {
|
|
19922
|
+
type: 'inbound',
|
|
19923
|
+
chatId: args.chatId,
|
|
19924
|
+
...(typeof args.threadId === 'number' ? { threadId: args.threadId } : {}),
|
|
19925
|
+
messageId: ts,
|
|
19926
|
+
user: args.user,
|
|
19927
|
+
userId: args.userId,
|
|
19928
|
+
ts,
|
|
19929
|
+
text,
|
|
19930
|
+
meta,
|
|
19931
|
+
}
|
|
19932
|
+
const delivered = ipcServer.sendToAgent(agentName, inbound)
|
|
19933
|
+
if (delivered) markClaudeBusyForInbound(inbound)
|
|
19934
|
+
process.stderr.write(
|
|
19935
|
+
`telegram gateway: reaction_dispatch agent=${agentName} chat=${args.chatId} ` +
|
|
19936
|
+
`emoji=${args.emoji} message_id=${args.messageId} delivered=${delivered}\n`,
|
|
19937
|
+
)
|
|
19938
|
+
if (!delivered) {
|
|
19939
|
+
pendingInboundBuffer.push(agentName, inbound)
|
|
19940
|
+
}
|
|
19941
|
+
}
|
|
19942
|
+
|
|
19828
19943
|
/**
|
|
19829
19944
|
* Dispatch a debounce-flushed batch as a synthetic InboundMessage via
|
|
19830
19945
|
* the same `ipcServer.sendToAgent` path the cron-fold-in uses.
|
|
@@ -19949,10 +20064,29 @@ async function handleMessageReaction(ctx: Context): Promise<void> {
|
|
|
19949
20064
|
// From here on we ignore failures rather than reject (the persist
|
|
19950
20065
|
// path above is the v1 contract; trigger is best-effort).
|
|
19951
20066
|
if (action === 'remove' || emoji === null) return
|
|
19952
|
-
if (!HISTORY_ENABLED) return // need history to identify bot-authored target
|
|
19953
20067
|
const reacter = update.user
|
|
19954
20068
|
if (!reacter) return // anonymous group-channel reactions — not user-attributable
|
|
19955
20069
|
|
|
20070
|
+
const reacterName = reacter.first_name ?? reacter.username ?? String(reacter.id)
|
|
20071
|
+
|
|
20072
|
+
// ─── reaction_dispatch (#2291) ───────────────────────────────────────
|
|
20073
|
+
// Event-driven dispatch of ANY qualifying reaction as a button-style
|
|
20074
|
+
// inbound turn. Independent of (and runs before) the bot-authored
|
|
20075
|
+
// `reactions` feedback path below; both may fire for one reaction.
|
|
20076
|
+
maybeDispatchReaction({
|
|
20077
|
+
chatId: chat_id,
|
|
20078
|
+
messageId: message_id,
|
|
20079
|
+
emoji,
|
|
20080
|
+
action,
|
|
20081
|
+
user: reacterName,
|
|
20082
|
+
userId: reacter.id,
|
|
20083
|
+
...(typeof update.message_thread_id === 'number'
|
|
20084
|
+
? { threadId: update.message_thread_id }
|
|
20085
|
+
: {}),
|
|
20086
|
+
})
|
|
20087
|
+
|
|
20088
|
+
if (!HISTORY_ENABLED) return // need history to identify bot-authored target
|
|
20089
|
+
|
|
19956
20090
|
const cfg = getReactionsConfig()
|
|
19957
20091
|
if (!cfg.enabled) return
|
|
19958
20092
|
|
|
@@ -20025,7 +20159,7 @@ async function handleMessageReaction(ctx: Context): Promise<void> {
|
|
|
20025
20159
|
ts: Date.now(),
|
|
20026
20160
|
preview,
|
|
20027
20161
|
userId: reacter.id,
|
|
20028
|
-
user:
|
|
20162
|
+
user: reacterName,
|
|
20029
20163
|
...(typeof update.message_thread_id === 'number'
|
|
20030
20164
|
? { threadId: update.message_thread_id }
|
|
20031
20165
|
: {}),
|
|
@@ -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
|
+
})
|