@rhizogrip/spore-plugin-teams 0.2.0 → 0.3.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/README.md CHANGED
@@ -104,6 +104,19 @@ spore's `replyTo` in `config.json`: `teams:<bot>/<object id>` for a person — t
104
104
  personal chat with them if they never wrote to it — or `teams:<bot>/<channel id>`, a new post in
105
105
  the channel. A place this bot's file does not route to the spore is refused, and the log says why.
106
106
 
107
+ ## Approvals
108
+
109
+ An approval your program asks in a personal chat or a channel this bot reaches
110
+ ([INTEGRATION §Asking a person to approve](../../INTEGRATION.md#asking-a-person-to-approve)) is
111
+ posted as an Adaptive Card — the request's `teams` card, or its `default` text and facts drawn as
112
+ a `TextBlock` and a `FactSet` — with an `Action.Execute` button per option, added by the bot. A
113
+ card of your own must be version 1.4 or later and carry no `Action.Submit`, `Action.Execute` or
114
+ `refresh` of its own; `Action.OpenUrl` is fine. A press is Microsoft's signed `adaptiveCard/action`
115
+ invoke, taken only from someone the approval names and answered at once — `Apply — recorded`, or
116
+ why it was not taken; what the person filled in on the card's inputs comes with it. The card is
117
+ then updated: replaced by the body your hook answered, or kept with its buttons gone and who chose
118
+ what below.
119
+
107
120
  ## What it guarantees
108
121
 
109
122
  - **A message reaches its spore once,** under `teams:<conversation id>:<activity id>` as its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizogrip/spore-plugin-teams",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Microsoft Teams for spores: a bot of yours in personal chats and channels, each reaching its spore, run as a service",
5
5
  "author": {
6
6
  "name": "Christophe Le Bars",
@@ -28,13 +28,13 @@
28
28
  "NOTICE"
29
29
  ],
30
30
  "peerDependencies": {
31
- "@rhizogrip/spore": "^1.23.0"
31
+ "@rhizogrip/spore": "^1.24.0"
32
32
  },
33
33
  "scripts": {
34
34
  "test": "vitest run"
35
35
  },
36
36
  "spore": {
37
- "works": "^1.23.0",
37
+ "works": "^1.24.0",
38
38
  "services": {
39
39
  "teams": {
40
40
  "what": "a bot of yours in Microsoft Teams, each personal chat and channel reaching its spore",
package/src/adapter.js CHANGED
@@ -16,6 +16,9 @@ const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost'])
16
16
  const THREAD = ';messageid='
17
17
  const COMMAND = /^\/([a-z]+)(?:\s+(.+))?$/is
18
18
  const SERVICE_URL = 'https://smba.trafficmanager.net/teams/'
19
+ const CARD = 'application/vnd.microsoft.card.adaptive'
20
+ const PRESSED = 'spore'
21
+ const ANSWERING = new Set(['Action.Submit', 'Action.Execute'])
19
22
  const SHAPE = '{ "app": "<app id>", "tenant": "<tenant id>", "secret": "${OPS_TEAMS_SECRET}", "listen": "127.0.0.1:3978", "people": { "<object id>": "<spore>" }, "channels": { "<channel id>": { "spores": ["<spore>"], "people": ["<object id>"] } } }' // eslint-disable-line no-template-curly-in-string
20
23
 
21
24
  const invalid = (path, problem, detail = `a bot is ${SHAPE}`) => new SporeError(`${path} ${problem}`, { type: 'TEAMS_BOT_INVALID', detail })
@@ -108,7 +111,7 @@ export const TEAMS = {
108
111
  routeOf: (conversation) => conversation.split(THREAD)[0],
109
112
  usage: { help: `${HELP}; ${EXTEND}.`, briefHelp: `${HELP}.`, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
110
113
  limits: { message: 40000, file: 0, editEveryMs: 2500, typingEveryMs: 4000 },
111
- can: { upload: false, remove: true, react: false, typing: false, reply: true },
114
+ can: { upload: false, remove: true, react: false, typing: false, reply: true, choose: true },
112
115
  reactions: null,
113
116
  showShared: 'working',
114
117
  hint (err, { file }) {
@@ -161,6 +164,48 @@ export function normalize (activity) {
161
164
 
162
165
  const tenantOf = (activity) => activity.conversation?.tenantId ?? activity.channelData?.tenant?.id
163
166
 
167
+ /** Whether anything in a card, however deep — an action set, a select action, a refresh — sends a press back to the bot. */
168
+ const answersBack = (value) => Array.isArray(value)
169
+ ? value.some(answersBack)
170
+ : record(value) && (ANSWERING.has(value.type) || Object.values(value).some(answersBack))
171
+
172
+ /** What an approval shows, as an Adaptive Card: the host's own, or the default body — its text, then its facts. */
173
+ const cardOf = ({ native, fallback }) => native
174
+ ? structuredClone(native)
175
+ : {
176
+ type: 'AdaptiveCard',
177
+ $schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
178
+ version: '1.5',
179
+ body: [
180
+ ...(fallback.text ? [{ type: 'TextBlock', text: fallback.text, wrap: true }] : []),
181
+ ...(fallback.facts?.length ? [{ type: 'FactSet', facts: fallback.facts.map(([title, value]) => ({ title, value })) }] : [])
182
+ ]
183
+ }
184
+
185
+ /** The card as it was shown, the relay's buttons gone and how it ended below. */
186
+ const closed = (card, outcome) => ({
187
+ ...card,
188
+ body: [...(card.body ?? []), { type: 'TextBlock', text: `**${outcome}**`, wrap: true }],
189
+ actions: (card.actions ?? []).filter(action => !(record(action.data) && Object.hasOwn(action.data, PRESSED)))
190
+ })
191
+
192
+ /**
193
+ * A press on one of the relay's buttons, as the relay takes it, from an `adaptiveCard/action`
194
+ * invoke: what Teams merged into the action's data besides the relay's own is what the person
195
+ * filled in.
196
+ */
197
+ export function choiceOf (activity) {
198
+ const personal = activity.conversation.conversationType === 'personal'
199
+ const { [PRESSED]: data = null, ...inputs } = record(activity.value?.action?.data) ? activity.value.action.data : {}
200
+ return {
201
+ conversation: personal ? activity.from.aadObjectId : activity.conversation.id,
202
+ data,
203
+ inputs: Object.keys(inputs).length ? inputs : null,
204
+ person: { id: activity.from.aadObjectId, name: activity.from.name, groups: [] },
205
+ where: personal ? 'a personal chat in Teams' : 'a Teams channel'
206
+ }
207
+ }
208
+
164
209
  /**
165
210
  * The live adapter. `receive({ onActivity, signal, log })` is the transport — the listener, or a
166
211
  * test's — and hands over each activity already checked. A post to a person goes to the chat they
@@ -212,6 +257,33 @@ export function createAdapter ({ connector, receive, app, tenant }) {
212
257
  ...(answering === null ? {} : { replyToId: answering })
213
258
  })
214
259
 
260
+ /** An activity into a conversation, answering its note: a person's chat, made when there is none yet; a thread; or a new post in a channel. */
261
+ const into = async (conversation, act) => {
262
+ if (!conversation.startsWith('19:')) {
263
+ const chat = await chatOf(conversation)
264
+ return `${chat} ${await connector.send(serviceOf(conversation), chat, act)}`
265
+ }
266
+ if (conversation.includes(THREAD)) return `${conversation} ${await connector.send(serviceOf(conversation), conversation, act)}`
267
+ const { replyToId, ...root } = act
268
+ const made = await connector.create(serviceOf(conversation), { isGroup: true, channelData: { channel: { id: conversation }, tenant: { id: tenant } }, activity: root })
269
+ return `${made.id} ${made.activityId}`
270
+ }
271
+
272
+ /**
273
+ * An invoke from one of the relay's buttons handed to `onChoice`, and answered as Teams wants an
274
+ * `adaptiveCard/action` answered: a short message, what the relay acknowledged it with.
275
+ */
276
+ const press = async (one, onChoice, log) => {
277
+ const answered = (value) => ({ statusCode: 200, type: 'application/vnd.microsoft.activity.message', value })
278
+ if (!one.from?.aadObjectId) {
279
+ log(`a press in ${one.conversation.id} from ${one.from?.name ?? 'someone'} with no Entra object id, not taken`)
280
+ return answered('who pressed this is not known')
281
+ }
282
+ let said = 'this button no longer does anything'
283
+ await onChoice({ ...choiceOf(one), acknowledge: async (text) => { if (text) said = text } })
284
+ return answered(said)
285
+ }
286
+
215
287
  return {
216
288
  ...TEAMS,
217
289
 
@@ -220,12 +292,13 @@ export function createAdapter ({ connector, receive, app, tenant }) {
220
292
  save = persist
221
293
  },
222
294
 
223
- listen: ({ onMessage, signal, log }) => receive({
295
+ listen: ({ onMessage, onChoice, signal, log }) => receive({
224
296
  signal,
225
297
  log,
226
298
  onActivity: async (one) => {
227
299
  if (tenantOf(one) !== tenant) return log(`dropped an activity from tenant ${tenantOf(one)}: this bot serves ${tenant}`)
228
300
  remember(one)
301
+ if (one.type === 'invoke' && one.name === 'adaptiveCard/action' && one.value?.trigger !== 'automatic') return press(one, onChoice, log)
229
302
  if (one.type !== 'message') return
230
303
  if (one.conversation.conversationType === 'groupChat') return log(`group chat ${one.conversation.id}: group chats are not served yet`)
231
304
  if (!one.from?.aadObjectId) return log(`a message in ${one.conversation.id} from ${one.from?.name ?? 'someone'} with no Entra object id, not taken`)
@@ -233,17 +306,7 @@ export function createAdapter ({ connector, receive, app, tenant }) {
233
306
  }
234
307
  }),
235
308
 
236
- post: async (conversation, text, { answering = null, markdown = false } = {}) => {
237
- if (!conversation.startsWith('19:')) {
238
- const chat = await chatOf(conversation)
239
- return `${chat} ${await connector.send(serviceOf(conversation), chat, activity(text, { markdown, answering }))}`
240
- }
241
- if (conversation.includes(THREAD)) {
242
- return `${conversation} ${await connector.send(serviceOf(conversation), conversation, activity(text, { markdown, answering }))}`
243
- }
244
- const made = await connector.create(serviceOf(conversation), { isGroup: true, channelData: { channel: { id: conversation }, tenant: { id: tenant } }, activity: activity(text, { markdown }) })
245
- return `${made.id} ${made.activityId}`
246
- },
309
+ post: (conversation, text, { answering = null, markdown = false } = {}) => into(conversation, activity(text, { markdown, answering })),
247
310
 
248
311
  edit: async (conversation, note, text) => {
249
312
  const where = placeOf(note)
@@ -253,6 +316,32 @@ export function createAdapter ({ connector, receive, app, tenant }) {
253
316
  remove: async (conversation, note) => {
254
317
  const where = placeOf(note)
255
318
  await connector.remove(serviceOf(conversation), where.conversation, where.id)
319
+ },
320
+
321
+ checkBody: (native) => {
322
+ if (native.type !== 'AdaptiveCard' || !(Number(native.version) >= 1.4)) {
323
+ return 'a Teams body is an Adaptive Card of version 1.4 or later, as { "type": "AdaptiveCard", "version": "1.5", "body": [ … ] }'
324
+ }
325
+ if (answersBack(native)) return 'a Teams body carries no Action.Submit or Action.Execute, nor a refresh — the buttons that answer are the ones spore adds'
326
+ return null
327
+ },
328
+
329
+ /** The question as a card, each choice an `Action.Execute` under it. */
330
+ ask: async (conversation, shown, choices) => {
331
+ const card = cardOf(shown)
332
+ card.actions = [
333
+ ...(card.actions ?? []),
334
+ ...choices.map(choice => ({ type: 'Action.Execute', title: choice.label, verb: 'answer', data: { [PRESSED]: choice.data }, ...(choice.style ? { style: choice.style } : {}) }))
335
+ ]
336
+ return { note: await into(conversation, { type: 'message', attachments: [{ contentType: CARD, content: card }] }), shown: card }
337
+ },
338
+
339
+ /** The question closed: replaced by `native` or `fallback`, or kept as `shown` with how it ended below. */
340
+ settle: async (conversation, note, replacement) => {
341
+ const card = replacement.shown ? closed(replacement.shown, replacement.outcome) : cardOf(replacement)
342
+ const where = placeOf(note)
343
+ await connector.update(serviceOf(conversation), where.conversation, where.id, { type: 'message', id: where.id, attachments: [{ contentType: CARD, content: card }] })
344
+ return card
256
345
  }
257
346
  }
258
347
  }
package/src/server.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Where the Bot Connector delivers: `POST /api/messages` on a loopback address, which the
3
3
  * operator's proxy makes the bot's public HTTPS endpoint. An activity is checked before it is
4
4
  * read, and answered 200 only once `onActivity` has it, so one the service could not take is
5
- * delivered again.
5
+ * delivered again — with the body `onActivity` answers, where an invoke needs one.
6
6
  */
7
7
 
8
8
  import { createServer } from 'node:http'
@@ -45,12 +45,14 @@ export function serve ({ host, port, verify, onActivity, signal, log, listening
45
45
  log(`refused an activity: ${refused}`)
46
46
  return answer(401)
47
47
  }
48
+ let reply
48
49
  try {
49
- await onActivity(activity)
50
+ reply = await onActivity(activity)
50
51
  } catch (err) {
51
52
  log(`activity ${activity.id} not taken — ${err.message}${err.detail ? ` — ${err.detail}` : ''}`)
52
53
  return answer(500)
53
54
  }
55
+ if (typeof reply === 'object' && reply !== null) return response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(reply))
54
56
  answer(200)
55
57
  })
56
58