@rhizogrip/spore-plugin-teams 0.2.0 → 0.3.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/README.md +16 -2
- package/package.json +3 -3
- package/src/adapter.js +111 -15
- package/src/server.js +4 -2
package/README.md
CHANGED
|
@@ -83,8 +83,9 @@ it mentions the bot, and the mention is not part of what the spore is told; the
|
|
|
83
83
|
wrote and where. The bot posts `working…` and shows what kind of call runs as it runs; the answer
|
|
84
84
|
is a new message — in a channel, in the thread of the message it answers — and `working…` is then
|
|
85
85
|
removed, or marked `answered below` where Teams will not remove it; in a channel thread, Teams shows
|
|
86
|
-
`This message has been deleted.` where it was. The answer is sent
|
|
87
|
-
|
|
86
|
+
`This message has been deleted.` where it was. The answer is sent in Teams' extended Markdown,
|
|
87
|
+
so tables and fenced code render; what the bot says itself — status, notices — is plain text,
|
|
88
|
+
line for line. What the turn left in `data/out/` is named, one line a file: files do not cross yet,
|
|
88
89
|
either way.
|
|
89
90
|
|
|
90
91
|
```
|
|
@@ -104,6 +105,19 @@ spore's `replyTo` in `config.json`: `teams:<bot>/<object id>` for a person — t
|
|
|
104
105
|
personal chat with them if they never wrote to it — or `teams:<bot>/<channel id>`, a new post in
|
|
105
106
|
the channel. A place this bot's file does not route to the spore is refused, and the log says why.
|
|
106
107
|
|
|
108
|
+
## Approvals
|
|
109
|
+
|
|
110
|
+
An approval your program asks in a personal chat or a channel this bot reaches
|
|
111
|
+
([INTEGRATION §Asking a person to approve](../../INTEGRATION.md#asking-a-person-to-approve)) is
|
|
112
|
+
posted as an Adaptive Card — the request's `teams` card, or its `default` text and facts drawn as
|
|
113
|
+
a `TextBlock` and a `FactSet` — with an `Action.Execute` button per option, added by the bot. A
|
|
114
|
+
card of your own must be version 1.4 or later and carry no `Action.Submit`, `Action.Execute` or
|
|
115
|
+
`refresh` of its own; `Action.OpenUrl` is fine. A press is Microsoft's signed `adaptiveCard/action`
|
|
116
|
+
invoke, taken only from someone the approval names and answered at once — `Apply — recorded`, or
|
|
117
|
+
why it was not taken; what the person filled in on the card's inputs comes with it. The card is
|
|
118
|
+
then updated: replaced by the body your hook answered, or kept with its buttons gone and who chose
|
|
119
|
+
what below.
|
|
120
|
+
|
|
107
121
|
## What it guarantees
|
|
108
122
|
|
|
109
123
|
- **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.
|
|
3
|
+
"version": "0.3.1",
|
|
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.
|
|
31
|
+
"@rhizogrip/spore": "^1.24.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"test": "vitest run"
|
|
35
35
|
},
|
|
36
36
|
"spore": {
|
|
37
|
-
"works": "^1.
|
|
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 }) {
|
|
@@ -159,8 +162,52 @@ export function normalize (activity) {
|
|
|
159
162
|
}
|
|
160
163
|
}
|
|
161
164
|
|
|
165
|
+
const escapeHtml = (text) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
166
|
+
|
|
162
167
|
const tenantOf = (activity) => activity.conversation?.tenantId ?? activity.channelData?.tenant?.id
|
|
163
168
|
|
|
169
|
+
/** Whether anything in a card, however deep — an action set, a select action, a refresh — sends a press back to the bot. */
|
|
170
|
+
const answersBack = (value) => Array.isArray(value)
|
|
171
|
+
? value.some(answersBack)
|
|
172
|
+
: record(value) && (ANSWERING.has(value.type) || Object.values(value).some(answersBack))
|
|
173
|
+
|
|
174
|
+
/** What an approval shows, as an Adaptive Card: the host's own, or the default body — its text, then its facts. */
|
|
175
|
+
const cardOf = ({ native, fallback }) => native
|
|
176
|
+
? structuredClone(native)
|
|
177
|
+
: {
|
|
178
|
+
type: 'AdaptiveCard',
|
|
179
|
+
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
|
180
|
+
version: '1.5',
|
|
181
|
+
body: [
|
|
182
|
+
...(fallback.text ? [{ type: 'TextBlock', text: fallback.text, wrap: true }] : []),
|
|
183
|
+
...(fallback.facts?.length ? [{ type: 'FactSet', facts: fallback.facts.map(([title, value]) => ({ title, value })) }] : [])
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The card as it was shown, the relay's buttons gone and how it ended below. */
|
|
188
|
+
const closed = (card, outcome) => ({
|
|
189
|
+
...card,
|
|
190
|
+
body: [...(card.body ?? []), { type: 'TextBlock', text: `**${outcome}**`, wrap: true }],
|
|
191
|
+
actions: (card.actions ?? []).filter(action => !(record(action.data) && Object.hasOwn(action.data, PRESSED)))
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A press on one of the relay's buttons, as the relay takes it, from an `adaptiveCard/action`
|
|
196
|
+
* invoke: what Teams merged into the action's data besides the relay's own is what the person
|
|
197
|
+
* filled in.
|
|
198
|
+
*/
|
|
199
|
+
export function choiceOf (activity) {
|
|
200
|
+
const personal = activity.conversation.conversationType === 'personal'
|
|
201
|
+
const { [PRESSED]: data = null, ...inputs } = record(activity.value?.action?.data) ? activity.value.action.data : {}
|
|
202
|
+
return {
|
|
203
|
+
conversation: personal ? activity.from.aadObjectId : activity.conversation.id,
|
|
204
|
+
data,
|
|
205
|
+
inputs: Object.keys(inputs).length ? inputs : null,
|
|
206
|
+
person: { id: activity.from.aadObjectId, name: activity.from.name, groups: [] },
|
|
207
|
+
where: personal ? 'a personal chat in Teams' : 'a Teams channel'
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
164
211
|
/**
|
|
165
212
|
* The live adapter. `receive({ onActivity, signal, log })` is the transport — the listener, or a
|
|
166
213
|
* test's — and hands over each activity already checked. A post to a person goes to the chat they
|
|
@@ -205,13 +252,45 @@ export function createAdapter ({ connector, receive, app, tenant }) {
|
|
|
205
252
|
return { conversation: note.slice(0, at), id: note.slice(at + 1) }
|
|
206
253
|
}
|
|
207
254
|
|
|
255
|
+
/**
|
|
256
|
+
* An answer in Teams' extended Markdown, which renders the tables and fenced code a model writes;
|
|
257
|
+
* what the relay says itself in Teams' HTML subset, escaped, each line on its own — its plain
|
|
258
|
+
* format runs lines together.
|
|
259
|
+
*/
|
|
208
260
|
const activity = (text, { markdown = false, answering = null } = {}) => ({
|
|
209
261
|
type: 'message',
|
|
210
|
-
text,
|
|
211
|
-
textFormat: markdown ? '
|
|
262
|
+
text: markdown ? text : escapeHtml(text).replace(/\n/g, '<br>'),
|
|
263
|
+
textFormat: markdown ? 'extendedmarkdown' : 'xml',
|
|
212
264
|
...(answering === null ? {} : { replyToId: answering })
|
|
213
265
|
})
|
|
214
266
|
|
|
267
|
+
/** 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. */
|
|
268
|
+
const into = async (conversation, act) => {
|
|
269
|
+
if (!conversation.startsWith('19:')) {
|
|
270
|
+
const chat = await chatOf(conversation)
|
|
271
|
+
return `${chat} ${await connector.send(serviceOf(conversation), chat, act)}`
|
|
272
|
+
}
|
|
273
|
+
if (conversation.includes(THREAD)) return `${conversation} ${await connector.send(serviceOf(conversation), conversation, act)}`
|
|
274
|
+
const { replyToId, ...root } = act
|
|
275
|
+
const made = await connector.create(serviceOf(conversation), { isGroup: true, channelData: { channel: { id: conversation }, tenant: { id: tenant } }, activity: root })
|
|
276
|
+
return `${made.id} ${made.activityId}`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* An invoke from one of the relay's buttons handed to `onChoice`, and answered as Teams wants an
|
|
281
|
+
* `adaptiveCard/action` answered: a short message, what the relay acknowledged it with.
|
|
282
|
+
*/
|
|
283
|
+
const press = async (one, onChoice, log) => {
|
|
284
|
+
const answered = (value) => ({ statusCode: 200, type: 'application/vnd.microsoft.activity.message', value })
|
|
285
|
+
if (!one.from?.aadObjectId) {
|
|
286
|
+
log(`a press in ${one.conversation.id} from ${one.from?.name ?? 'someone'} with no Entra object id, not taken`)
|
|
287
|
+
return answered('who pressed this is not known')
|
|
288
|
+
}
|
|
289
|
+
let said = 'this button no longer does anything'
|
|
290
|
+
await onChoice({ ...choiceOf(one), acknowledge: async (text) => { if (text) said = text } })
|
|
291
|
+
return answered(said)
|
|
292
|
+
}
|
|
293
|
+
|
|
215
294
|
return {
|
|
216
295
|
...TEAMS,
|
|
217
296
|
|
|
@@ -220,12 +299,13 @@ export function createAdapter ({ connector, receive, app, tenant }) {
|
|
|
220
299
|
save = persist
|
|
221
300
|
},
|
|
222
301
|
|
|
223
|
-
listen: ({ onMessage, signal, log }) => receive({
|
|
302
|
+
listen: ({ onMessage, onChoice, signal, log }) => receive({
|
|
224
303
|
signal,
|
|
225
304
|
log,
|
|
226
305
|
onActivity: async (one) => {
|
|
227
306
|
if (tenantOf(one) !== tenant) return log(`dropped an activity from tenant ${tenantOf(one)}: this bot serves ${tenant}`)
|
|
228
307
|
remember(one)
|
|
308
|
+
if (one.type === 'invoke' && one.name === 'adaptiveCard/action' && one.value?.trigger !== 'automatic') return press(one, onChoice, log)
|
|
229
309
|
if (one.type !== 'message') return
|
|
230
310
|
if (one.conversation.conversationType === 'groupChat') return log(`group chat ${one.conversation.id}: group chats are not served yet`)
|
|
231
311
|
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 +313,7 @@ export function createAdapter ({ connector, receive, app, tenant }) {
|
|
|
233
313
|
}
|
|
234
314
|
}),
|
|
235
315
|
|
|
236
|
-
post:
|
|
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
|
-
},
|
|
316
|
+
post: (conversation, text, { answering = null, markdown = false } = {}) => into(conversation, activity(text, { markdown, answering })),
|
|
247
317
|
|
|
248
318
|
edit: async (conversation, note, text) => {
|
|
249
319
|
const where = placeOf(note)
|
|
@@ -253,6 +323,32 @@ export function createAdapter ({ connector, receive, app, tenant }) {
|
|
|
253
323
|
remove: async (conversation, note) => {
|
|
254
324
|
const where = placeOf(note)
|
|
255
325
|
await connector.remove(serviceOf(conversation), where.conversation, where.id)
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
checkBody: (native) => {
|
|
329
|
+
if (native.type !== 'AdaptiveCard' || !(Number(native.version) >= 1.4)) {
|
|
330
|
+
return 'a Teams body is an Adaptive Card of version 1.4 or later, as { "type": "AdaptiveCard", "version": "1.5", "body": [ … ] }'
|
|
331
|
+
}
|
|
332
|
+
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'
|
|
333
|
+
return null
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
/** The question as a card, each choice an `Action.Execute` under it. */
|
|
337
|
+
ask: async (conversation, shown, choices) => {
|
|
338
|
+
const card = cardOf(shown)
|
|
339
|
+
card.actions = [
|
|
340
|
+
...(card.actions ?? []),
|
|
341
|
+
...choices.map(choice => ({ type: 'Action.Execute', title: choice.label, verb: 'answer', data: { [PRESSED]: choice.data }, ...(choice.style ? { style: choice.style } : {}) }))
|
|
342
|
+
]
|
|
343
|
+
return { note: await into(conversation, { type: 'message', attachments: [{ contentType: CARD, content: card }] }), shown: card }
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
/** The question closed: replaced by `native` or `fallback`, or kept as `shown` with how it ended below. */
|
|
347
|
+
settle: async (conversation, note, replacement) => {
|
|
348
|
+
const card = replacement.shown ? closed(replacement.shown, replacement.outcome) : cardOf(replacement)
|
|
349
|
+
const where = placeOf(note)
|
|
350
|
+
await connector.update(serviceOf(conversation), where.conversation, where.id, { type: 'message', id: where.id, attachments: [{ contentType: CARD, content: card }] })
|
|
351
|
+
return card
|
|
256
352
|
}
|
|
257
353
|
}
|
|
258
354
|
}
|
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
|
|