@s0nderlabs/anima-plugin-telegram 0.21.21 → 0.22.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@s0nderlabs/anima-plugin-telegram",
3
- "version": "0.21.21",
3
+ "version": "0.22.1",
4
4
  "type": "module",
5
5
  "description": "Telegram gateway plugin for anima — long-poll bot, debounced dispatch, reactions, allowlist",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "test": "bun test"
40
40
  },
41
41
  "dependencies": {
42
- "@s0nderlabs/anima-core": "0.21.21",
42
+ "@s0nderlabs/anima-core": "0.22.1",
43
43
  "grammy": "^1.42.0",
44
44
  "zod": "^3.23.8"
45
45
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
- import { formatInboundPreview, formatTelegramChannel } from './format'
2
+ import { formatInboundPreview, formatTelegramChannel, stripTelegramChannelEnvelope } from './format'
3
+ import { parseBypassCommand } from './session-state'
3
4
 
4
5
  describe('formatTelegramChannel', () => {
5
6
  it('wraps text in channel tags with username', () => {
@@ -44,6 +45,51 @@ describe('formatTelegramChannel', () => {
44
45
  })
45
46
  })
46
47
 
48
+ describe('stripTelegramChannelEnvelope', () => {
49
+ it('strips the envelope and returns inner text', () => {
50
+ expect(
51
+ stripTelegramChannelEnvelope(
52
+ '<channel source="telegram" chat="42" user="el">hello world</channel>',
53
+ ),
54
+ ).toBe('hello world')
55
+ })
56
+ it('returns input unchanged when no envelope present', () => {
57
+ expect(stripTelegramChannelEnvelope('hello world')).toBe('hello world')
58
+ expect(stripTelegramChannelEnvelope('/yolo')).toBe('/yolo')
59
+ })
60
+ it('preserves multi-line + nested-bracket content', () => {
61
+ expect(
62
+ stripTelegramChannelEnvelope(
63
+ '<channel source="telegram" chat="1" user="a">line 1\nline 2 with &lt;br&gt; tag</channel>',
64
+ ),
65
+ ).toBe('line 1\nline 2 with &lt;br&gt; tag')
66
+ })
67
+ // v0.22.0 regression: TG bypass commands (/yolo /perms /reset) fell through
68
+ // to the brain because parseBypassCommand was given the wrapped string. The
69
+ // wrapped string starts with '<channel' not '/', so the bypass parser
70
+ // returns null. After the strip, parseBypassCommand intercepts correctly.
71
+ it('lets parseBypassCommand intercept after strip (regression)', () => {
72
+ const wrapped = '<channel source="telegram" chat="1" user="el">/yolo</channel>'
73
+ expect(parseBypassCommand(wrapped)).toBeNull()
74
+ expect(parseBypassCommand(stripTelegramChannelEnvelope(wrapped))).toEqual({
75
+ command: '/yolo',
76
+ args: [],
77
+ })
78
+ })
79
+ it('handles /perms strict and /reset the same way', () => {
80
+ const yolo = '<channel source="telegram" chat="1" user="el">/perms strict</channel>'
81
+ expect(parseBypassCommand(stripTelegramChannelEnvelope(yolo))).toEqual({
82
+ command: '/perms',
83
+ args: ['strict'],
84
+ })
85
+ const reset = '<channel source="telegram" chat="1" user="el">/reset</channel>'
86
+ expect(parseBypassCommand(stripTelegramChannelEnvelope(reset))).toEqual({
87
+ command: '/reset',
88
+ args: [],
89
+ })
90
+ })
91
+ })
92
+
47
93
  describe('formatInboundPreview', () => {
48
94
  it('renders short message verbatim', () => {
49
95
  expect(
package/src/format.ts CHANGED
@@ -20,6 +20,24 @@ export function formatTelegramChannel(input: FormatTelegramChannelInput): string
20
20
  return `<channel source="telegram" chat="${input.chatId}" user="${safeUser}">${safeText}</channel>`
21
21
  }
22
22
 
23
+ /**
24
+ * Inverse of `formatTelegramChannel`: strip the channel envelope and return
25
+ * the raw inner text. Used by the gateway's TG slot to feed bypass-command
26
+ * parsing the un-wrapped string (the wrapper would make `parseBypassCommand`'s
27
+ * `startsWith('/')` check fail and silently drop `/yolo` etc).
28
+ *
29
+ * v0.22.0: extracted into plugin-telegram so the regex source lives next to
30
+ * its forward counterpart `formatTelegramChannel`. If we ever change the
31
+ * envelope shape (add fields, swap quoting), both stay in sync.
32
+ *
33
+ * Returns the input unchanged when there is no envelope (idempotent — safe to
34
+ * call on already-stripped or non-TG input).
35
+ */
36
+ const CHANNEL_ENVELOPE_RE = /^<channel[^>]*>([\s\S]*)<\/channel>$/
37
+ export function stripTelegramChannelEnvelope(text: string): string {
38
+ return text.replace(CHANNEL_ENVELOPE_RE, '$1')
39
+ }
40
+
23
41
  /**
24
42
  * One-line preview of an inbound TG message for TUI rows + activity log.
25
43
  * Truncated to 80 chars; never includes the bot token or any envelope bytes.
package/src/index.ts CHANGED
@@ -34,7 +34,11 @@ export {
34
34
  formatApprovalResolution,
35
35
  } from './listener'
36
36
  export { buildSessionKey, sanitizeAgentName } from './session-key'
37
- export { formatTelegramChannel, formatInboundPreview } from './format'
37
+ export {
38
+ formatTelegramChannel,
39
+ formatInboundPreview,
40
+ stripTelegramChannelEnvelope,
41
+ } from './format'
38
42
  export { RateLimiter } from './limits'
39
43
  export { sanitizeInbound, type SanitizeReason, type SanitizeResult } from './sanitize'
40
44
  export { formatPairingMessage } from './pairing-flow'
@@ -4,8 +4,47 @@ import {
4
4
  formatMarkdownV2,
5
5
  isMarkdownParseError,
6
6
  stripMarkdownV2,
7
+ wrapGfmTablesInCodeBlocks,
7
8
  } from './markdown'
8
9
 
10
+ describe('wrapGfmTablesInCodeBlocks (v0.22.1)', () => {
11
+ it('wraps a basic 3x3 GFM table in fences', () => {
12
+ const input = `Here you go:
13
+
14
+ | Mode | Behavior | Modal |
15
+ |--------|----------|-------|
16
+ | yolo | auto | no |
17
+ | prompt | approve | yes |
18
+ | strict | deny | no |
19
+
20
+ That's the table.`
21
+ const out = wrapGfmTablesInCodeBlocks(input)
22
+ expect(out).toContain('```\n| Mode')
23
+ expect(out).toContain('| strict | deny | no |\n```')
24
+ expect(out).toContain("That's the table.")
25
+ })
26
+
27
+ it('passes through text with no tables unchanged', () => {
28
+ expect(wrapGfmTablesInCodeBlocks('just prose')).toBe('just prose')
29
+ expect(wrapGfmTablesInCodeBlocks('| not | a table without separator')).toBe(
30
+ '| not | a table without separator',
31
+ )
32
+ })
33
+
34
+ it('handles alignment colons in separator row', () => {
35
+ const input = `| a | b |
36
+ |:--|--:|
37
+ | 1 | 2 |`
38
+ const out = wrapGfmTablesInCodeBlocks(input)
39
+ expect(out.startsWith('```')).toBe(true)
40
+ expect(out.endsWith('```')).toBe(true)
41
+ })
42
+
43
+ it('keeps non-table pipes intact', () => {
44
+ expect(wrapGfmTablesInCodeBlocks('use | as separator')).toBe('use | as separator')
45
+ })
46
+ })
47
+
9
48
  describe('escapeMarkdownV2', () => {
10
49
  it('escapes all reserved characters', () => {
11
50
  expect(escapeMarkdownV2('a_b*c')).toBe('a\\_b\\*c')
package/src/markdown.ts CHANGED
@@ -64,6 +64,13 @@ export function isMarkdownParseError(err: unknown): boolean {
64
64
  export function formatMarkdownV2(content: string): string {
65
65
  if (!content) return content
66
66
 
67
+ // GFM tables don't render in MarkdownV2 — pipes show literally and columns
68
+ // misalign. Wrap detected table blocks in ``` fences so TG renders them
69
+ // monospace + preserves column alignment. Detection: a line starting with
70
+ // `|` followed by a separator row (`|---|---|`) makes the start of a table;
71
+ // contiguous `|...|` lines are pulled in until the first non-table line.
72
+ const wrapped = wrapGfmTablesInCodeBlocks(content)
73
+
67
74
  const placeholders: string[] = []
68
75
  const ph = (value: string): string => {
69
76
  const key = `\x00PH${placeholders.length}\x00`
@@ -71,7 +78,7 @@ export function formatMarkdownV2(content: string): string {
71
78
  return key
72
79
  }
73
80
 
74
- let text = content
81
+ let text = wrapped
75
82
 
76
83
  text = text.replace(/```(?:[^\n]*\n)?[\s\S]*?```/g, raw => {
77
84
  const newlineIdx = raw.indexOf('\n', 3)
@@ -145,6 +152,48 @@ function escapeStrayParens(text: string): string {
145
152
  .join('')
146
153
  }
147
154
 
155
+ /**
156
+ * Detect GFM table blocks in the brain's reply and wrap them in ``` fences.
157
+ * TG MarkdownV2 doesn't render tables; without the fence, pipes show literally
158
+ * and columns drift. With the fence, TG renders monospace + preserves the
159
+ * brain's space padding so columns line up.
160
+ *
161
+ * Table boundary: a `|...|` row immediately followed by a separator row
162
+ * `|---|---|` (or `:---:`, `---|---`, etc.) is the start. Contiguous `|...|`
163
+ * data rows are pulled into the same block. First non-pipe line ends it.
164
+ */
165
+ const TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
166
+ const TABLE_ROW_RE = /^\s*\|.+\|?\s*$/
167
+
168
+ export function wrapGfmTablesInCodeBlocks(text: string): string {
169
+ const lines = text.split('\n')
170
+ const out: string[] = []
171
+ let i = 0
172
+ while (i < lines.length) {
173
+ const line = lines[i] ?? ''
174
+ if (
175
+ TABLE_ROW_RE.test(line) &&
176
+ i + 1 < lines.length &&
177
+ TABLE_SEPARATOR_RE.test(lines[i + 1] ?? '')
178
+ ) {
179
+ const block: string[] = [line, lines[i + 1] ?? '']
180
+ let j = i + 2
181
+ while (j < lines.length && TABLE_ROW_RE.test(lines[j] ?? '')) {
182
+ block.push(lines[j] ?? '')
183
+ j += 1
184
+ }
185
+ out.push('```')
186
+ out.push(...block)
187
+ out.push('```')
188
+ i = j
189
+ continue
190
+ }
191
+ out.push(line)
192
+ i += 1
193
+ }
194
+ return out.join('\n')
195
+ }
196
+
148
197
  function isInsideLinkUrl(seg: string, closeIdx: number): boolean {
149
198
  let depth = 0
150
199
  for (let j = closeIdx - 1; j >= Math.max(closeIdx - 2000, 0); j--) {
package/src/progress.ts CHANGED
@@ -94,6 +94,15 @@ export class ProgressTracker {
94
94
  private canEdit = true
95
95
  private pendingTimer: ReturnType<typeof setTimeout> | null = null
96
96
  private finalized = false
97
+ /**
98
+ * Serialize all flush operations so the start-event's sendMessage finishes
99
+ * (assigning messageId) before any end-event's flush runs. Without this
100
+ * lock, fast tools that fire start+end within ~5ms (e.g. strict-deny path)
101
+ * would race two parallel sendMessage calls, producing a duplicate "tool
102
+ * starting" message followed by a separate "tool ended ✗" message instead
103
+ * of one in-place edit. v0.22.1 fix.
104
+ */
105
+ private flushLock: Promise<void> = Promise.resolve()
97
106
 
98
107
  constructor(
99
108
  private readonly bot: Bot,
@@ -118,7 +127,13 @@ export class ProgressTracker {
118
127
  if (idx == null || this.lines[idx] == null) return
119
128
  this.lines[idx] = `${this.lines[idx]} ${ev.ok === false ? '✗' : '✓'}`
120
129
  }
121
- await this.flush()
130
+ // Serialize: subsequent flushes wait for any in-flight sendMessage to
131
+ // resolve so the second flush sees the assigned messageId and routes to
132
+ // editMessageText, not a second sendMessage. v0.22.1 fix for fast-tool
133
+ // double-message regression.
134
+ const previous = this.flushLock
135
+ this.flushLock = previous.then(() => this.flush()).catch(() => {})
136
+ await this.flushLock
122
137
  }
123
138
 
124
139
  /**
@@ -131,7 +146,12 @@ export class ProgressTracker {
131
146
  clearTimeout(this.pendingTimer)
132
147
  this.pendingTimer = null
133
148
  }
134
- await this.flush(true)
149
+ // Serialize through the lock so finalize doesn't race a still-flying
150
+ // push from the brain. Important for fast tools where end-event flush
151
+ // and finalize() race the same scratch message edit.
152
+ const previous = this.flushLock
153
+ this.flushLock = previous.then(() => this.flush(true)).catch(() => {})
154
+ await this.flushLock
135
155
  this.finalized = true
136
156
  }
137
157