node-red-contrib-knx-ultimate 6.3.14 → 6.3.16

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.
@@ -0,0 +1,357 @@
1
+ const CHAT_CONTEXT_VERSION = 1
2
+ const CHAT_CONTEXT_MAX_BYTES = 512 * 1024
3
+ const CHAT_CONTEXT_MAX_SESSIONS = 50
4
+ const CHAT_CONTEXT_MAX_TURNS_PER_SESSION = 8
5
+ const CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION = 20
6
+ const CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION = 20
7
+ const CHAT_CONTEXT_MAX_QUESTION_CHARS = 4000
8
+ const CHAT_CONTEXT_MAX_REPLY_CHARS = 8000
9
+ const CHAT_CONTEXT_MAX_INSTRUCTION_CHARS = 2000
10
+
11
+ const clampText = (value, maxChars) => String(value === undefined || value === null ? '' : value)
12
+ .trim()
13
+ .slice(0, Math.max(0, Number(maxChars) || 0))
14
+
15
+ const normalizeSessionId = value => clampText(value || 'default', 160) || 'default'
16
+
17
+ const createEmptyKnxAiChatContext = () => ({
18
+ version: CHAT_CONTEXT_VERSION,
19
+ createdAt: new Date().toISOString(),
20
+ updatedAt: new Date().toISOString(),
21
+ sessions: []
22
+ })
23
+
24
+ const normalizeTurn = (turn) => {
25
+ if (!turn || typeof turn !== 'object' || Array.isArray(turn)) return null
26
+ const question = clampText(turn.question, CHAT_CONTEXT_MAX_QUESTION_CHARS)
27
+ const reply = clampText(turn.reply, CHAT_CONTEXT_MAX_REPLY_CHARS)
28
+ if (!question && !reply) return null
29
+ return {
30
+ at: clampText(turn.at || new Date().toISOString(), 64),
31
+ question,
32
+ reply
33
+ }
34
+ }
35
+
36
+ const normalizeInstruction = (instruction) => {
37
+ if (!instruction || typeof instruction !== 'object' || Array.isArray(instruction)) return null
38
+ const text = clampText(instruction.text, CHAT_CONTEXT_MAX_INSTRUCTION_CHARS)
39
+ if (!text) return null
40
+ return {
41
+ at: clampText(instruction.at || new Date().toISOString(), 64),
42
+ text
43
+ }
44
+ }
45
+
46
+ const normalizeCameraWatch = (watch) => {
47
+ if (!watch || typeof watch !== 'object' || Array.isArray(watch)) return null
48
+ const id = clampText(watch.id, 160)
49
+ const cameraId = clampText(watch.cameraId, 160)
50
+ const cameraName = clampText(watch.cameraName, 240)
51
+ const eventType = clampText(watch.eventType, 80)
52
+ if (!id || (!cameraId && !cameraName) || !eventType) return null
53
+ return {
54
+ id,
55
+ createdAt: clampText(watch.createdAt || new Date().toISOString(), 64),
56
+ cameraId,
57
+ cameraName,
58
+ eventType,
59
+ scopeId: clampText(watch.scopeId, 160),
60
+ scopeName: clampText(watch.scopeName, 240),
61
+ objectTypes: Array.from(new Set((Array.isArray(watch.objectTypes) ? watch.objectTypes : [])
62
+ .map(value => clampText(value, 80).toLocaleLowerCase())
63
+ .filter(Boolean))).slice(0, 12),
64
+ cooldownSeconds: Math.max(10, Math.min(86400, Number(watch.cooldownSeconds) || 60)),
65
+ sendSnapshot: watch.sendSnapshot !== false,
66
+ language: clampText(watch.language || 'en', 8).toLocaleLowerCase()
67
+ }
68
+ }
69
+
70
+ const normalizeSession = (session) => {
71
+ if (!session || typeof session !== 'object' || Array.isArray(session)) return null
72
+ const id = normalizeSessionId(session.id)
73
+ const turns = (Array.isArray(session.turns) ? session.turns : [])
74
+ .map(normalizeTurn)
75
+ .filter(Boolean)
76
+ .slice(-CHAT_CONTEXT_MAX_TURNS_PER_SESSION)
77
+ const instructions = (Array.isArray(session.instructions) ? session.instructions : [])
78
+ .map(normalizeInstruction)
79
+ .filter(Boolean)
80
+ .slice(-CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION)
81
+ const cameraWatches = (Array.isArray(session.cameraWatches) ? session.cameraWatches : [])
82
+ .map(normalizeCameraWatch)
83
+ .filter(Boolean)
84
+ .slice(-CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION)
85
+ return {
86
+ id,
87
+ updatedAt: clampText(session.updatedAt || new Date().toISOString(), 64),
88
+ turns,
89
+ instructions,
90
+ cameraWatches
91
+ }
92
+ }
93
+
94
+ const normalizeKnxAiChatContext = (value = {}) => {
95
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
96
+ const byId = new Map()
97
+ ;(Array.isArray(source.sessions) ? source.sessions : []).forEach((item) => {
98
+ const session = normalizeSession(item)
99
+ if (!session) return
100
+ byId.delete(session.id)
101
+ byId.set(session.id, session)
102
+ })
103
+ return {
104
+ version: CHAT_CONTEXT_VERSION,
105
+ createdAt: clampText(source.createdAt || new Date().toISOString(), 64),
106
+ updatedAt: clampText(source.updatedAt || new Date().toISOString(), 64),
107
+ sessions: Array.from(byId.values()).slice(-CHAT_CONTEXT_MAX_SESSIONS)
108
+ }
109
+ }
110
+
111
+ const findSession = (context, sessionId) => {
112
+ const id = normalizeSessionId(sessionId)
113
+ return normalizeKnxAiChatContext(context).sessions.find(session => session.id === id) || null
114
+ }
115
+
116
+ const touchSession = (context, sessionId) => {
117
+ const target = normalizeKnxAiChatContext(context)
118
+ const id = normalizeSessionId(sessionId)
119
+ const existing = target.sessions.find(session => session.id === id)
120
+ const session = existing || { id, updatedAt: new Date().toISOString(), turns: [], instructions: [], cameraWatches: [] }
121
+ target.sessions = target.sessions.filter(item => item.id !== id)
122
+ target.sessions.push(session)
123
+ target.sessions = target.sessions.slice(-CHAT_CONTEXT_MAX_SESSIONS)
124
+ target.updatedAt = new Date().toISOString()
125
+ session.updatedAt = target.updatedAt
126
+ return { target, session }
127
+ }
128
+
129
+ const explicitInstructionPatterns = [
130
+ /^\s*(?:please[\s,]+)?remember\b/i,
131
+ /^\s*from now on\b/i,
132
+ /^\s*(?:please[\s,]+)?(?:always|never)\b/i,
133
+ /^\s*(?:per favore[\s,]+)?(?:ricordati|ricorda|ricordarsi|memorizza)\b/i,
134
+ /^\s*(?:d['’]ora in poi|da ora in poi)\b/i,
135
+ /^\s*(?:usa sempre|non usare mai|evita sempre)\b/i,
136
+ /^\s*(?:bitte[\s,]+)?(?:merk dir|erinnere dich)\b/i,
137
+ /^\s*(?:ab jetzt|von jetzt an)\b/i,
138
+ /^\s*(?:s['’]il te pla[iî]t[\s,]+)?(?:souviens-toi|rappelle-toi|m[eé]morise)\b/i,
139
+ /^\s*(?:dor[eé]navant|[àa] partir de maintenant)\b/i,
140
+ /^\s*(?:por favor[\s,]+)?(?:recuerda|memoriza)\b/i,
141
+ /^\s*(?:a partir de ahora|de ahora en adelante)\b/i,
142
+ /^\s*(?:请)?记住/,
143
+ /^\s*从现在开始/
144
+ ]
145
+
146
+ const extractExplicitKnxAiChatInstruction = (question) => {
147
+ const text = clampText(question, CHAT_CONTEXT_MAX_INSTRUCTION_CHARS)
148
+ if (!text) return ''
149
+ return explicitInstructionPatterns.some(pattern => pattern.test(text)) ? text : ''
150
+ }
151
+
152
+ const addKnxAiChatTurn = (context, { sessionId, question, reply, at } = {}) => {
153
+ const { target, session } = touchSession(context, sessionId)
154
+ const turn = normalizeTurn({ at, question, reply })
155
+ if (turn) session.turns.push(turn)
156
+ session.turns = session.turns.slice(-CHAT_CONTEXT_MAX_TURNS_PER_SESSION)
157
+
158
+ const explicitInstruction = extractExplicitKnxAiChatInstruction(question)
159
+ if (explicitInstruction) {
160
+ const normalized = explicitInstruction.toLocaleLowerCase()
161
+ session.instructions = session.instructions.filter(item => item.text.toLocaleLowerCase() !== normalized)
162
+ session.instructions.push({
163
+ at: clampText(at || new Date().toISOString(), 64),
164
+ text: explicitInstruction
165
+ })
166
+ session.instructions = session.instructions.slice(-CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION)
167
+ }
168
+ return target
169
+ }
170
+
171
+ const clearKnxAiChatSession = (context, sessionId) => {
172
+ const target = normalizeKnxAiChatContext(context)
173
+ const id = normalizeSessionId(sessionId)
174
+ target.sessions = target.sessions.filter(session => session.id !== id)
175
+ target.updatedAt = new Date().toISOString()
176
+ return target
177
+ }
178
+
179
+ const addKnxAiCameraWatch = (context, { sessionId, watch } = {}) => {
180
+ const normalized = normalizeCameraWatch(watch)
181
+ if (!normalized) return normalizeKnxAiChatContext(context)
182
+ const { target, session } = touchSession(context, sessionId)
183
+ session.cameraWatches = (Array.isArray(session.cameraWatches) ? session.cameraWatches : [])
184
+ .filter(item => item.id !== normalized.id)
185
+ session.cameraWatches.push(normalized)
186
+ session.cameraWatches = session.cameraWatches.slice(-CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION)
187
+ return target
188
+ }
189
+
190
+ const removeKnxAiCameraWatches = (context, { sessionId, predicate } = {}) => {
191
+ const { target, session } = touchSession(context, sessionId)
192
+ const before = Array.isArray(session.cameraWatches) ? session.cameraWatches.length : 0
193
+ session.cameraWatches = (Array.isArray(session.cameraWatches) ? session.cameraWatches : [])
194
+ .filter(watch => !(typeof predicate === 'function' && predicate(watch)))
195
+ return { context: target, removed: Math.max(0, before - session.cameraWatches.length) }
196
+ }
197
+
198
+ const listKnxAiCameraWatches = (context, sessionId) => getKnxAiChatSession(context, sessionId).cameraWatches.slice()
199
+
200
+ const listAllKnxAiCameraWatches = (context) => normalizeKnxAiChatContext(context).sessions.flatMap(session => {
201
+ return session.cameraWatches.map(watch => Object.assign({ sessionId: session.id }, watch))
202
+ })
203
+
204
+ const getKnxAiChatSession = (context, sessionId) => {
205
+ const session = findSession(context, sessionId)
206
+ return session || {
207
+ id: normalizeSessionId(sessionId),
208
+ updatedAt: '',
209
+ turns: [],
210
+ instructions: [],
211
+ cameraWatches: []
212
+ }
213
+ }
214
+
215
+ const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } = {}) => {
216
+ const session = getKnxAiChatSession(context, sessionId)
217
+ if (!session.instructions.length && !session.turns.length && !session.cameraWatches.length) return ''
218
+ const lines = []
219
+ if (session.instructions.length) {
220
+ lines.push('PERSISTENT CHAT INSTRUCTIONS AND PREFERENCES (explicitly supplied by this user; follow them unless they conflict with safety or the KNX contract; newer entries override older conflicting entries):')
221
+ session.instructions.forEach(item => lines.push(`- ${item.text.replace(/\r?\n/g, ' ')}`))
222
+ }
223
+ if (session.turns.length) {
224
+ if (lines.length) lines.push('')
225
+ lines.push('RECENT CONVERSATION:')
226
+ session.turns.forEach((turn) => {
227
+ lines.push(`User: ${turn.question}`)
228
+ lines.push(`Assistant: ${turn.reply}`)
229
+ })
230
+ }
231
+ if (session.cameraWatches.length) {
232
+ if (lines.length) lines.push('')
233
+ lines.push('ACTIVE CAMERA WATCHES (persistent notification rules created by this chat):')
234
+ session.cameraWatches.forEach((watch) => {
235
+ const camera = watch.cameraName || watch.cameraId
236
+ const scope = watch.scopeName || watch.scopeId
237
+ const objects = watch.objectTypes.length ? `; objects ${watch.objectTypes.join(', ')}` : ''
238
+ lines.push(`- ${watch.id}: ${camera}; event ${watch.eventType}${scope ? `; scope ${scope}` : ''}${objects}; cooldown ${watch.cooldownSeconds}s`)
239
+ })
240
+ }
241
+ return lines.join('\n').slice(0, Math.max(1000, Number(maxChars) || 16000))
242
+ }
243
+
244
+ const escapeMarkdownText = value => clampText(value, CHAT_CONTEXT_MAX_REPLY_CHARS)
245
+ .replace(/\r?\n/g, ' ')
246
+ .replace(/</g, '&lt;')
247
+ .replace(/>/g, '&gt;')
248
+
249
+ const renderKnxAiChatContext = (context) => {
250
+ const target = normalizeKnxAiChatContext(context)
251
+ target.updatedAt = new Date().toISOString()
252
+ const metadata = Buffer.from(JSON.stringify(target), 'utf8').toString('base64')
253
+ const lines = [
254
+ '<!-- KNX_AI_CHAT_CONTEXT_V1_BASE64',
255
+ metadata,
256
+ 'KNX_AI_CHAT_CONTEXT_END -->',
257
+ '',
258
+ '# KNX AI Chat Context',
259
+ '',
260
+ `Updated: ${target.updatedAt}`,
261
+ '',
262
+ 'This bounded file stores recent chat turns and explicit long-term instructions separately for each chat session.',
263
+ ''
264
+ ]
265
+ target.sessions.forEach((session) => {
266
+ lines.push(`## Session ${escapeMarkdownText(session.id)}`, '')
267
+ lines.push('### Persistent instructions', '')
268
+ if (!session.instructions.length) lines.push('_None._')
269
+ session.instructions.forEach(item => lines.push(`- ${escapeMarkdownText(item.at)} — ${escapeMarkdownText(item.text)}`))
270
+ lines.push('', '### Camera watches', '')
271
+ if (!session.cameraWatches.length) lines.push('_None._')
272
+ session.cameraWatches.forEach((watch) => {
273
+ const camera = watch.cameraName || watch.cameraId
274
+ const scope = watch.scopeName || watch.scopeId
275
+ const objects = watch.objectTypes.length ? `; objects ${watch.objectTypes.join(', ')}` : ''
276
+ lines.push(`- ${escapeMarkdownText(watch.id)} — ${escapeMarkdownText(camera)}; event ${escapeMarkdownText(watch.eventType)}${scope ? `; scope ${escapeMarkdownText(scope)}` : ''}${objects}; cooldown ${watch.cooldownSeconds}s`)
277
+ })
278
+ lines.push('', '### Recent turns', '')
279
+ if (!session.turns.length) lines.push('_None._')
280
+ session.turns.forEach((turn) => {
281
+ lines.push(`- ${escapeMarkdownText(turn.at)} — **User:** ${escapeMarkdownText(turn.question)}`)
282
+ lines.push(` **Assistant:** ${escapeMarkdownText(turn.reply)}`)
283
+ })
284
+ lines.push('')
285
+ })
286
+ return { markdown: lines.join('\n'), context: target }
287
+ }
288
+
289
+ const buildKnxAiChatContextMarkdown = ({ context, maxBytes = CHAT_CONTEXT_MAX_BYTES } = {}) => {
290
+ const targetBytes = Math.max(64 * 1024, Math.min(CHAT_CONTEXT_MAX_BYTES, Number(maxBytes) || CHAT_CONTEXT_MAX_BYTES))
291
+ let bounded = normalizeKnxAiChatContext(context)
292
+ let rendered = renderKnxAiChatContext(bounded)
293
+ while (Buffer.byteLength(rendered.markdown, 'utf8') > targetBytes) {
294
+ const sessionWithTurns = bounded.sessions.find(session => session.turns.length > 0)
295
+ if (sessionWithTurns) sessionWithTurns.turns.shift()
296
+ else if (bounded.sessions.length > 1) bounded.sessions.shift()
297
+ else {
298
+ const sessionWithInstructions = bounded.sessions.find(session => session.instructions.length > 0)
299
+ if (!sessionWithInstructions) break
300
+ sessionWithInstructions.instructions.shift()
301
+ }
302
+ rendered = renderKnxAiChatContext(bounded)
303
+ bounded = rendered.context
304
+ }
305
+ return {
306
+ markdown: rendered.markdown,
307
+ context: rendered.context,
308
+ bytes: Buffer.byteLength(rendered.markdown, 'utf8'),
309
+ maxBytes: targetBytes
310
+ }
311
+ }
312
+
313
+ const parseKnxAiChatContextMarkdown = (markdown) => {
314
+ const text = String(markdown || '')
315
+ const match = text.match(/<!-- KNX_AI_CHAT_CONTEXT_V1_BASE64\s*\n([A-Za-z0-9+/=\r\n]+?)\nKNX_AI_CHAT_CONTEXT_END -->/)
316
+ if (!match) return createEmptyKnxAiChatContext()
317
+ try {
318
+ const metadata = Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8')
319
+ return normalizeKnxAiChatContext(JSON.parse(metadata))
320
+ } catch (error) {
321
+ return createEmptyKnxAiChatContext()
322
+ }
323
+ }
324
+
325
+ const conversationMapFromKnxAiChatContext = (context) => {
326
+ const result = new Map()
327
+ normalizeKnxAiChatContext(context).sessions.forEach((session) => {
328
+ result.set(session.id, session.turns.map(turn => ({
329
+ question: turn.question,
330
+ reply: turn.reply
331
+ })))
332
+ })
333
+ return result
334
+ }
335
+
336
+ module.exports = {
337
+ CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION,
338
+ CHAT_CONTEXT_MAX_BYTES,
339
+ CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION,
340
+ CHAT_CONTEXT_MAX_SESSIONS,
341
+ CHAT_CONTEXT_MAX_TURNS_PER_SESSION,
342
+ addKnxAiCameraWatch,
343
+ addKnxAiChatTurn,
344
+ buildKnxAiChatContextMarkdown,
345
+ buildKnxAiChatPromptContext,
346
+ clearKnxAiChatSession,
347
+ conversationMapFromKnxAiChatContext,
348
+ createEmptyKnxAiChatContext,
349
+ extractExplicitKnxAiChatInstruction,
350
+ getKnxAiChatSession,
351
+ listAllKnxAiCameraWatches,
352
+ listKnxAiCameraWatches,
353
+ normalizeKnxAiChatContext,
354
+ normalizeCameraWatch,
355
+ parseKnxAiChatContextMarkdown,
356
+ removeKnxAiCameraWatches
357
+ }
@@ -1,7 +1,7 @@
1
1
  const HOME_MEMORY_VERSION = 1
2
2
  const HOME_MEMORY_MIN_KB = 64
3
- const HOME_MEMORY_MAX_KB = 1024
4
- const HOME_MEMORY_DEFAULT_KB = 256
3
+ const HOME_MEMORY_MAX_KB = 5120
4
+ const HOME_MEMORY_DEFAULT_KB = 5120
5
5
  const HOME_MEMORY_MAX_EDUCATION_CHARS = 16000
6
6
  const HOME_MEMORY_MAX_OBSERVATIONS = 120
7
7
  const HOME_MEMORY_MAX_HABITS = 80
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.3.14",
6
+ "version": "6.3.16",
7
7
  "description": "KNX Ultimate is the most advanced KNX integration for Node-RED, providing secure KNX/IP communication, routing, ETS project import, Philips Hue, Matter Controller and Matter Bridge (control matter device via KNX and expose KNX GA via Matter), MQTT, diagnostics with AI, virtual devices, and powerful automation nodes. Build professional, reliable, and scalable smart home and building automation projects with minimal effort.",
8
8
  "files": [
9
9
  "nodes/",
@@ -52,6 +52,23 @@ if (content && typeof content === 'object') {
52
52
  }
53
53
  content = String(content === undefined || content === null ? '' : content);
54
54
 
55
+ const image = msg.knxAi && msg.knxAi.image;
56
+ if (image && Buffer.isBuffer(image.data)) {
57
+ msg.payload = {
58
+ chatId: chatId,
59
+ type: 'photo',
60
+ content: image.data,
61
+ options: {
62
+ caption: content.slice(0, 1024)
63
+ },
64
+ fileOptions: {
65
+ filename: image.filename || 'camera-snapshot.jpg',
66
+ contentType: image.mediaType || 'image/jpeg'
67
+ }
68
+ };
69
+ return msg;
70
+ }
71
+
55
72
  const telegramPayload = {
56
73
  chatId: chatId,
57
74
  type: 'message',
@@ -74,6 +91,141 @@ if (confirmation && confirmation.required === true && Array.isArray(confirmation
74
91
  }
75
92
 
76
93
  msg.payload = telegramPayload;
94
+ return msg;`
95
+ },
96
+ {
97
+ id: 'redbot-telegram',
98
+ title: 'RedBot / node-red-contrib-chatbot (Telegram)',
99
+ inputCode: `// RedBot Telegram Receiver -> KNX AI
100
+ // RedBot normalizes Telegram text and inline-button postbacks in msg.payload.
101
+ const redbot = msg.payload;
102
+ if (!redbot || typeof redbot !== 'object') return;
103
+ if (redbot.transport && redbot.transport !== 'telegram') return;
104
+
105
+ const chatId = redbot.chatId;
106
+ const content = typeof redbot.content === 'string' ? redbot.content.trim() : '';
107
+ if (chatId === undefined || chatId === null || content === '') return;
108
+ if (redbot.type !== 'message') return;
109
+
110
+ msg.sessionId = String(chatId);
111
+ msg.language =
112
+ (msg.originalMessage && msg.originalMessage.from && msg.originalMessage.from.language_code) ||
113
+ redbot.language ||
114
+ msg.language ||
115
+ '';
116
+
117
+ const action = content.toLowerCase();
118
+ if (action === 'confirm' || action === 'cancel') {
119
+ msg.topic = action;
120
+ msg.knxAi = Object.assign({}, msg.knxAi, {
121
+ sessionId: String(chatId),
122
+ confirm: action === 'confirm'
123
+ });
124
+ return msg;
125
+ }
126
+
127
+ msg.topic = 'ask';
128
+ msg.prompt = content;
129
+ return msg;`,
130
+ outputCode: `// KNX AI chat output -> RedBot Telegram Sender
131
+ // Preserves RedBot conversation helpers and emits either a plain message
132
+ // or inline postback buttons for KNX confirmation/cancellation.
133
+ const source = msg.inputMessage && typeof msg.inputMessage === 'object'
134
+ ? msg.inputMessage
135
+ : inputMessage;
136
+ const sourcePayload = source && source.payload && typeof source.payload === 'object'
137
+ ? source.payload
138
+ : {};
139
+ const originalMessage = source && source.originalMessage && typeof source.originalMessage === 'object'
140
+ ? source.originalMessage
141
+ : {};
142
+ const chatId = sourcePayload.chatId !== undefined
143
+ ? sourcePayload.chatId
144
+ : originalMessage.chat && originalMessage.chat.id;
145
+ if (chatId === undefined || chatId === null) return;
146
+
147
+ // RedBot's Sender uses these helpers to resolve the active bot and chat context.
148
+ ['originalMessage', 'chat', 'api', 'client', 'redBot'].forEach(function (key) {
149
+ if (msg[key] === undefined && source && source[key] !== undefined) {
150
+ msg[key] = source[key];
151
+ }
152
+ });
153
+
154
+ // A persisted camera watch can fire immediately after Node-RED restarts,
155
+ // before a new inbound RedBot message has recreated its conversation helpers.
156
+ // RedBot's Telegram Sender still needs transport metadata and a chat accessor,
157
+ // even when tracking is disabled. Supply the smallest compatible outbound
158
+ // envelope; the configured Sender remains responsible for choosing the bot.
159
+ if (!msg.originalMessage || typeof msg.originalMessage !== 'object') {
160
+ msg.originalMessage = {};
161
+ }
162
+ if (!msg.originalMessage.transport) msg.originalMessage.transport = sourcePayload.transport || 'telegram';
163
+ if (msg.originalMessage.chatId === undefined) msg.originalMessage.chatId = chatId;
164
+ if (sourcePayload.userId !== undefined && msg.originalMessage.userId === undefined) {
165
+ msg.originalMessage.userId = sourcePayload.userId;
166
+ }
167
+ if (typeof msg.chat !== 'function') {
168
+ msg.chat = function () {
169
+ return {
170
+ get: function () { return {}; },
171
+ set: function () { return undefined; },
172
+ remove: function () { return undefined; }
173
+ };
174
+ };
175
+ }
176
+ if (typeof msg.get !== 'function') {
177
+ msg.get = function (key) {
178
+ if (key === 'userId') return sourcePayload.userId;
179
+ if (key === 'chatId') return chatId;
180
+ if (key === 'transport') return sourcePayload.transport || 'telegram';
181
+ return undefined;
182
+ };
183
+ }
184
+
185
+ let content = msg.payload;
186
+ if (content && typeof content === 'object') {
187
+ content = content.error || content.message || JSON.stringify(content);
188
+ }
189
+ content = String(content === undefined || content === null ? '' : content);
190
+
191
+ const image = msg.knxAi && msg.knxAi.image;
192
+ if (image && Buffer.isBuffer(image.data)) {
193
+ msg.payload = {
194
+ transport: sourcePayload.transport || 'telegram',
195
+ chatId: chatId,
196
+ type: 'photo',
197
+ inbound: false,
198
+ content: image.data,
199
+ filename: image.filename || 'camera-snapshot.jpg',
200
+ mimeType: image.mediaType || 'image/jpeg',
201
+ caption: content.slice(0, 1024)
202
+ };
203
+ if (sourcePayload.userId !== undefined) msg.payload.userId = sourcePayload.userId;
204
+ return msg;
205
+ }
206
+
207
+ const redbotPayload = {
208
+ transport: sourcePayload.transport || 'telegram',
209
+ chatId: chatId,
210
+ type: 'message',
211
+ inbound: false,
212
+ content: content
213
+ };
214
+ if (sourcePayload.userId !== undefined) redbotPayload.userId = sourcePayload.userId;
215
+
216
+ const confirmation = msg.knxAi && msg.knxAi.confirmationRequest;
217
+ if (confirmation && confirmation.required === true && Array.isArray(confirmation.actions)) {
218
+ redbotPayload.type = 'inline-buttons';
219
+ redbotPayload.buttons = confirmation.actions.map(function (action) {
220
+ return {
221
+ type: 'postback',
222
+ label: action.label,
223
+ value: action.callbackData
224
+ };
225
+ });
226
+ }
227
+
228
+ msg.payload = redbotPayload;
77
229
  return msg;`
78
230
  }
79
231
  ]