buzz-me-in 2.2.0-beta.4 → 2.2.0

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/.env.example CHANGED
@@ -66,6 +66,18 @@ SHUTDOWN_GRACE_MS=10000
66
66
  # unlock bothers you.
67
67
  HOMEKIT_HANGUP_GRACE_MS=8000
68
68
 
69
+ # Optional labels for numbers you expect to call in, so the journal says who
70
+ # rang rather than only a number. The intercom panel is normally the only
71
+ # caller; anything else is a wrong number or a dialler that found the number.
72
+ # Comma-separated <E.164>=<label> pairs. Labels may contain spaces, up to 64
73
+ # characters, and each number may appear once.
74
+ # Purely descriptive: an unlabelled caller is logged as "unknown" and still
75
+ # rings the doorbell. Nothing is ever rejected on the strength of this, because
76
+ # the building's dialler could change without warning and a silently blocked
77
+ # visitor is much worse than a labelled unknown one.
78
+ # Leave unset to omit the caller field entirely.
79
+ #KNOWN_CALLERS=+15551234567=Intercom,+15557654321=Building management
80
+
69
81
  # Logging
70
82
  # LOG_LEVEL controls minimum emitted level: debug | info | warn | error
71
83
  # Default is info when unset/invalid.
package/README.md CHANGED
@@ -1,10 +1,14 @@
1
1
  # buzz-me-in
2
2
 
3
- Apartment intercom to Apple HomeKit, bridged over Twilio.
4
-
3
+ [![npm](https://img.shields.io/npm/v/buzz-me-in.svg?style=flat-square)](https://www.npmjs.com/package/buzz-me-in)
4
+ [![npm@beta](https://img.shields.io/npm/v/buzz-me-in/beta.svg?style=flat-square&label=npm%40beta)](https://www.npmjs.com/package/buzz-me-in/v/beta)
5
+ [![npm](https://img.shields.io/npm/dt/buzz-me-in.svg?style=flat-square)](https://www.npmjs.com/package/buzz-me-in)
6
+ [![GitHub last commit](https://img.shields.io/github/last-commit/atdr/buzz-me-in.svg?style=flat-square)](https://github.com/atdr/buzz-me-in)
5
7
  [![CI](https://img.shields.io/github/actions/workflow/status/atdr/buzz-me-in/ci.yml?style=flat-square&label=CI)](https://github.com/atdr/buzz-me-in/actions/workflows/ci.yml)
6
8
  [![Coverage](https://img.shields.io/codecov/c/github/atdr/buzz-me-in?style=flat-square&label=coverage)](https://codecov.io/gh/atdr/buzz-me-in)
7
9
 
10
+ Apartment intercom to Apple HomeKit, bridged over Twilio.
11
+
8
12
  A Raspberry Pi server that bridges an apartment intercom system into Apple HomeKit. When someone presses the intercom buzzer, your iPhone/HomePod receives a doorbell notification with a live camera tile. You can hear the caller, speak back, and unlock the door — all from the Home app or via Siri.
9
13
 
10
14
  ## Project docs
package/package.json CHANGED
@@ -1,7 +1,18 @@
1
1
  {
2
2
  "name": "buzz-me-in",
3
- "version": "2.2.0-beta.4",
3
+ "version": "2.2.0",
4
4
  "description": "HomeKit-compatible apartment intercom via Twilio media streams",
5
+ "keywords": [
6
+ "homekit",
7
+ "intercom",
8
+ "twilio",
9
+ "doorbell",
10
+ "smart-home",
11
+ "raspberry-pi",
12
+ "hap-nodejs",
13
+ "ffmpeg",
14
+ "nodejs"
15
+ ],
5
16
  "main": "server.js",
6
17
  "bin": {
7
18
  "buzz-me-in": "./server.js"
@@ -42,7 +53,7 @@
42
53
  "node": ">=20"
43
54
  },
44
55
  "dependencies": {
45
- "dotenv": "^17.4.2",
56
+ "dotenv": "^18.0.1",
46
57
  "hap-nodejs": "^0.14.2",
47
58
  "qrcode-terminal": "^0.12.0",
48
59
  "twilio": "^6.1.0",
package/server.js CHANGED
@@ -165,6 +165,33 @@ function buildTwiml(callSid) {
165
165
  </Response>`;
166
166
  }
167
167
 
168
+ /**
169
+ * A repeated form field parses to an array, so take the first value. A sender
170
+ * can repeat From freely; the signature covers the whole body, not its shape.
171
+ *
172
+ * @param {string | string[] | undefined} value
173
+ * @returns {string | undefined}
174
+ */
175
+ function firstFormValue(value) {
176
+ if (Array.isArray(value)) return value.length ? value[0] : undefined;
177
+ return value;
178
+ }
179
+
180
+ /**
181
+ * Label for a calling number, from KNOWN_CALLERS. Returns 'unknown' for a
182
+ * number we have no label for, and null when nothing was configured, so the
183
+ * field is absent rather than misleadingly saying 'unknown' on every call.
184
+ *
185
+ * @param {string | string[] | undefined} from
186
+ * @returns {string | null}
187
+ */
188
+ function describeCaller(from) {
189
+ if (config.knownCallers.size === 0) return null;
190
+ const number = firstFormValue(from);
191
+ if (!number) return 'unknown';
192
+ return config.knownCallers.get(number.trim()) || 'unknown';
193
+ }
194
+
168
195
  async function handleTwimlRequest(req, res) {
169
196
  twimlLogger.info('Incoming TwiML request', {
170
197
  event: 'twiml-request',
@@ -200,9 +227,18 @@ async function handleTwimlRequest(req, res) {
200
227
  const formData = parseFormUrlEncoded(rawBody);
201
228
  const callSid = normalizeCallSid(formData.CallSid);
202
229
  const body = buildTwiml(callSid);
230
+ // Who rang. Logged here rather than on twiml-request, which fires before the
231
+ // body is read and before the signature is checked: an unverified From is
232
+ // whatever the sender typed. forwardedFrom is logged as Twilio reports it,
233
+ // but carriers fill it inconsistently: on EE it is simply the dialled number
234
+ // on every call, so it cannot tell a diverted call from a direct one.
235
+ const caller = describeCaller(formData.From);
203
236
  twimlLogger.info('TwiML response generated', {
204
237
  event: 'twiml-response',
205
238
  callSid: callSid || undefined,
239
+ from: firstFormValue(formData.From) || undefined,
240
+ forwardedFrom: firstFormValue(formData.ForwardedFrom) || undefined,
241
+ caller: caller || undefined,
206
242
  });
207
243
  res.writeHead(200, {
208
244
  'Content-Type': 'text/xml',
@@ -506,6 +542,7 @@ module.exports = {
506
542
  main,
507
543
  beginShutdown,
508
544
  buildTwiml,
545
+ describeCaller,
509
546
  handleRequest,
510
547
  isAuthorizedForStatus,
511
548
  isValidTwilioRequest,
@@ -96,6 +96,50 @@ if (!MEDIA_SIGNATURE_MODES.includes(twilioMediaSignatureMode)) {
96
96
  fail(`TWILIO_MEDIA_SIGNATURE_MODE must be one of: ${MEDIA_SIGNATURE_MODES.join(', ')}`);
97
97
  }
98
98
 
99
+ // Optional labels for numbers we expect to see calling in, so the journal says
100
+ // who rang rather than only a number. The intercom panel is the only caller
101
+ // that should normally appear; anything else is a wrong number or a dialler
102
+ // finding an open PSTN number, and being able to tell them apart at a glance is
103
+ // the point. Purely descriptive: nothing is rejected on the strength of it, and
104
+ // an unknown caller still rings the doorbell, because the building's dialler
105
+ // could change without warning and a silently blocked visitor is far worse
106
+ // than a labelled unknown one.
107
+ //
108
+ // Format: comma-separated E.164=Label pairs, e.g.
109
+ // KNOWN_CALLERS=+15551234567=Intercom,+15557654321=Building management
110
+ const E164 = /^\+[1-9]\d{1,14}$/;
111
+
112
+ function parseKnownCallers(name) {
113
+ const raw = optional(name);
114
+ const callers = new Map();
115
+ if (!raw) return callers;
116
+
117
+ for (const entry of raw.split(',')) {
118
+ const trimmed = entry.trim();
119
+ if (!trimmed) continue;
120
+ const separator = trimmed.indexOf('=');
121
+ if (separator === -1) {
122
+ fail(`${name} entries must be <E.164>=<label>, got "${trimmed}"`);
123
+ }
124
+ const number = trimmed.slice(0, separator).trim();
125
+ const label = trimmed.slice(separator + 1).trim();
126
+ if (!E164.test(number)) {
127
+ fail(`${name} entry "${trimmed}" does not start with an E.164 number`);
128
+ }
129
+ if (!label) {
130
+ fail(`${name} entry "${trimmed}" has an empty label`);
131
+ }
132
+ if (label.length > 64) {
133
+ fail(`${name} label for ${number} is longer than 64 characters`);
134
+ }
135
+ if (callers.has(number)) {
136
+ fail(`${name} lists ${number} more than once`);
137
+ }
138
+ callers.set(number, label);
139
+ }
140
+ return callers;
141
+ }
142
+
99
143
  const twilioWebhookBaseUrl = sanitizeBaseUrl(
100
144
  optional('TWILIO_WEBHOOK_BASE_URL') || `https://${tunnelHostname}`
101
145
  );
@@ -139,6 +183,7 @@ module.exports = {
139
183
  twilioMediaPayloadMaxBytes: parsePositiveInt('TWILIO_MEDIA_PAYLOAD_MAX_BYTES', '512'),
140
184
  shutdownGraceMs: parsePositiveInt('SHUTDOWN_GRACE_MS', '10000'),
141
185
  homekitHangupGraceMs: parsePositiveInt('HOMEKIT_HANGUP_GRACE_MS', '8000'),
186
+ knownCallers: parseKnownCallers('KNOWN_CALLERS'),
142
187
  streamAuthSecret,
143
188
  streamAuthTtlSec: 90,
144
189
  statusApiToken,