@rhizogrip/spore-plugin-teams 0.1.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 +42 -7
- package/package.json +3 -3
- package/src/adapter.js +121 -24
- package/src/server.js +4 -2
package/README.md
CHANGED
|
@@ -18,12 +18,25 @@ spore service define teams@ops && spore service start teams@ops
|
|
|
18
18
|
|
|
19
19
|
## The bot
|
|
20
20
|
|
|
21
|
-
Teams reaches a bot through Azure: an **Azure Bot**,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
Teams reaches a bot through Azure: an **Azure Bot**, made in the Azure portal with *Single Tenant*
|
|
22
|
+
as its type and a new Microsoft App ID — Microsoft no longer makes multi-tenant bots, and the
|
|
23
|
+
service asks your tenant for its token. Its app registration in Entra ID gives the bot file its
|
|
24
|
+
`app` (the Microsoft App ID), its `tenant`, and a client secret — the secret's value, kept in
|
|
25
|
+
`environment` under the variable `secret` names. The Azure Bot's messaging endpoint is a public
|
|
26
|
+
HTTPS address of yours, which your proxy forwards to `listen` — a loopback address: the service
|
|
27
|
+
never faces the internet itself. With nginx, a path per bot:
|
|
28
|
+
|
|
29
|
+
```nginx
|
|
30
|
+
location = /ops/api/messages { proxy_pass http://127.0.0.1:3978/api/messages; }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Turn on the Azure Bot's Microsoft Teams channel. Then a Teams app for the bot, in the Teams
|
|
34
|
+
Developer Portal: under App features, a bot with the Microsoft App ID, in the personal and team
|
|
35
|
+
scopes. A manifest of version 1.25 or later with the team scope must say
|
|
36
|
+
`"supportsChannelFeatures": "tier1"`, or Teams refuses the package; it lets the app into private
|
|
37
|
+
and shared channels too, which the service does not serve yet, so leave it out of those. Publish
|
|
38
|
+
it to your organization and approve it in the Teams admin center, under Manage apps: an app
|
|
39
|
+
uploaded for one person can stay blocked by the tenant's app policies.
|
|
27
40
|
|
|
28
41
|
Every activity is checked before it is read: a token Microsoft signed for this app id, with a key
|
|
29
42
|
it endorses for Teams, for the `serviceUrl` the activity names. One that fails is answered 401 and
|
|
@@ -47,6 +60,14 @@ the channel, written out. A channel has one spore: the bot speaks there as itsel
|
|
|
47
60
|
is a second bot. `details` shows the calls themselves in the `working…` message rather than their
|
|
48
61
|
kinds. The file is read on every message, so a person or a channel is added by editing a line.
|
|
49
62
|
|
|
63
|
+
A bot that serves people who should see their answers and not the machinery takes `"brief": true`
|
|
64
|
+
beside `people` and `channels`: each conversation is then told its spore's state and its answers,
|
|
65
|
+
and nothing of how it runs. `/status` says `working`, `idle`, `paused` or how many messages wait —
|
|
66
|
+
never the model, the spend, the turns or the limits; an answer comes without how the turn ended, a
|
|
67
|
+
turn with none says `no answer this time` and the log says why; a limit reached shows as `wrapping
|
|
68
|
+
up`; and `/extend` is not offered, since what a turn may spend is yours. A person or a channel takes
|
|
69
|
+
`"brief"` as well, `false` to be told everything in a brief bot; `details` is refused beside it.
|
|
70
|
+
|
|
50
71
|
Someone not in the file gets silence, and the log gets the line to add:
|
|
51
72
|
|
|
52
73
|
```
|
|
@@ -61,7 +82,8 @@ In a personal chat, anything written reaches the spore. In a channel, a message
|
|
|
61
82
|
it mentions the bot, and the mention is not part of what the spore is told; the spore is told who
|
|
62
83
|
wrote and where. The bot posts `working…` and shows what kind of call runs as it runs; the answer
|
|
63
84
|
is a new message — in a channel, in the thread of the message it answers — and `working…` is then
|
|
64
|
-
removed, or marked `answered below` where Teams will not remove it
|
|
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 as Teams
|
|
65
87
|
markdown. What the turn left in `data/out/` is named, one line a file: files do not cross yet,
|
|
66
88
|
either way.
|
|
67
89
|
|
|
@@ -82,6 +104,19 @@ spore's `replyTo` in `config.json`: `teams:<bot>/<object id>` for a person — t
|
|
|
82
104
|
personal chat with them if they never wrote to it — or `teams:<bot>/<channel id>`, a new post in
|
|
83
105
|
the channel. A place this bot's file does not route to the spore is refused, and the log says why.
|
|
84
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
|
+
|
|
85
120
|
## What it guarantees
|
|
86
121
|
|
|
87
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.
|
|
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.
|
|
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
|
@@ -8,20 +8,29 @@
|
|
|
8
8
|
import { SporeError } from '@rhizogrip/spore'
|
|
9
9
|
|
|
10
10
|
const FIELDS = ['app', 'tenant', 'secret', 'listen', 'people', 'channels']
|
|
11
|
-
const PERSON_FIELDS = ['spore', 'details']
|
|
12
|
-
const CHANNEL_FIELDS = ['spores', 'people', 'details']
|
|
11
|
+
const PERSON_FIELDS = ['spore', 'details', 'brief']
|
|
12
|
+
const CHANNEL_FIELDS = ['spores', 'people', 'details', 'brief']
|
|
13
13
|
const PERSON = /^[0-9a-f][0-9a-f-]*$/i
|
|
14
14
|
const CHANNEL = /^19:[^\s;]+@thread\.[a-z0-9]+$/i
|
|
15
15
|
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 })
|
|
22
25
|
|
|
23
26
|
const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
24
27
|
|
|
28
|
+
const flags = (path, kind, id, entry) => {
|
|
29
|
+
for (const name of ['details', 'brief']) {
|
|
30
|
+
if (entry[name] !== undefined && typeof entry[name] !== 'boolean') throw invalid(path, `has a ${kind} ${id} whose ${name} is not true or false`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
const strays = (path, kind, id, entry, takes) => {
|
|
26
35
|
const extra = Object.keys(entry).filter(key => !takes.includes(key))
|
|
27
36
|
if (!extra.length) return
|
|
@@ -41,9 +50,10 @@ export function listenOf (raw, path) {
|
|
|
41
50
|
|
|
42
51
|
/**
|
|
43
52
|
* The bot's app and tenant, where it listens, and its routing: `people`, each person's personal
|
|
44
|
-
* chat by their Entra object id — `"<spore>"` or `{ "spore", "details" }` — and `channels`,
|
|
45
|
-
* standard channel by its id, with its one spore and who may address it there: object ids, or
|
|
46
|
-
* for everyone in the channel. `details` shows the calls themselves rather than their kinds
|
|
53
|
+
* chat by their Entra object id — `"<spore>"` or `{ "spore", "details", "brief" }` — and `channels`,
|
|
54
|
+
* each standard channel by its id, with its one spore and who may address it there: object ids, or
|
|
55
|
+
* `*` for everyone in the channel. `details` shows the calls themselves rather than their kinds;
|
|
56
|
+
* `brief` is the bot's own, for this conversation.
|
|
47
57
|
*/
|
|
48
58
|
function routes (raw, path) {
|
|
49
59
|
for (const field of ['app', 'tenant']) {
|
|
@@ -63,28 +73,29 @@ function routes (raw, path) {
|
|
|
63
73
|
if (!PERSON.test(person)) throw invalid(path, `names "${person}" among its people, which is not an Entra object id`, 'a person is their object id in Entra ID; a channel\'s id begins 19: and goes under channels')
|
|
64
74
|
if (record(entry)) {
|
|
65
75
|
strays(path, 'person', person, entry, PERSON_FIELDS)
|
|
66
|
-
|
|
76
|
+
flags(path, 'person', person, entry)
|
|
67
77
|
}
|
|
68
78
|
const spore = record(entry) ? entry.spore : entry
|
|
69
79
|
if (typeof spore !== 'string' || !spore) throw invalid(path, `maps person ${person} to something that is not a spore`, 'each person is their object id against the name of the spore they reach, or { "spore": "<name>", "details": true }')
|
|
70
|
-
table.set(person, { spores: [spore], details: record(entry) && entry.details === true, show: null, people: null, overhear: false })
|
|
80
|
+
table.set(person, { spores: [spore], details: record(entry) && entry.details === true, show: null, brief: record(entry) ? entry.brief ?? null : null, people: null, overhear: false })
|
|
71
81
|
}
|
|
72
82
|
for (const [channel, entry] of entries('channels')) {
|
|
73
83
|
if (!CHANNEL.test(channel)) throw invalid(path, `names "${channel}" among its channels, which is not a channel id`, 'a channel id is 19:…@thread.tacv2, from the channel\'s link in Teams')
|
|
74
84
|
if (!record(entry)) throw invalid(path, `has a channel ${channel} that is not { "spores": [...], "people": [...] }`)
|
|
75
85
|
strays(path, 'channel', channel, entry, CHANNEL_FIELDS)
|
|
76
|
-
|
|
86
|
+
flags(path, 'channel', channel, entry)
|
|
77
87
|
if (!Array.isArray(entry.spores) || !entry.spores.length || !entry.spores.every(spore => typeof spore === 'string' && spore)) throw invalid(path, `has a channel ${channel} with no spores`)
|
|
78
88
|
if (entry.spores.length > 1) throw invalid(path, `gives channel ${channel} ${entry.spores.length} spores`, 'a bot speaks in a channel as itself, so a channel has one spore — a second spore there is a second bot')
|
|
79
89
|
if (!Array.isArray(entry.people) || !entry.people.every(who => who === '*' || (typeof who === 'string' && PERSON.test(who)))) {
|
|
80
90
|
throw invalid(path, `has a channel ${channel} whose people are not object ids or *`, 'people is who may address its spore — ["<object id>", …], or ["*"] for everyone in the channel, written out')
|
|
81
91
|
}
|
|
82
|
-
table.set(channel, { spores: entry.spores, details: entry.details === true, show: null, people: entry.people, overhear: false })
|
|
92
|
+
table.set(channel, { spores: entry.spores, details: entry.details === true, show: null, brief: entry.brief ?? null, people: entry.people, overhear: false })
|
|
83
93
|
}
|
|
84
94
|
return table
|
|
85
95
|
}
|
|
86
96
|
|
|
87
|
-
const HELP = 'Anything you write to me in a personal chat goes to the spore; in a channel, mention me. /status says what it is doing; /stop ends the turn it is running and sets aside what you sent that it had not read; /wrap [10m] asks it to wrap up
|
|
97
|
+
const HELP = 'Anything you write to me in a personal chat goes to the spore; in a channel, mention me. /status says what it is doing; /stop ends the turn it is running and sets aside what you sent that it had not read; /wrap [10m] asks it to wrap up'
|
|
98
|
+
const EXTEND = '/extend wall 30m gives it more, as far as its spore.cmk allows'
|
|
88
99
|
|
|
89
100
|
export const TEAMS = {
|
|
90
101
|
kind: 'teams',
|
|
@@ -98,9 +109,9 @@ export const TEAMS = {
|
|
|
98
109
|
example: SHAPE,
|
|
99
110
|
routes,
|
|
100
111
|
routeOf: (conversation) => conversation.split(THREAD)[0],
|
|
101
|
-
usage: { help: HELP
|
|
112
|
+
usage: { help: `${HELP}; ${EXTEND}.`, briefHelp: `${HELP}.`, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
|
|
102
113
|
limits: { message: 40000, file: 0, editEveryMs: 2500, typingEveryMs: 4000 },
|
|
103
|
-
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 },
|
|
104
115
|
reactions: null,
|
|
105
116
|
showShared: 'working',
|
|
106
117
|
hint (err, { file }) {
|
|
@@ -153,6 +164,48 @@ export function normalize (activity) {
|
|
|
153
164
|
|
|
154
165
|
const tenantOf = (activity) => activity.conversation?.tenantId ?? activity.channelData?.tenant?.id
|
|
155
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
|
+
|
|
156
209
|
/**
|
|
157
210
|
* The live adapter. `receive({ onActivity, signal, log })` is the transport — the listener, or a
|
|
158
211
|
* test's — and hands over each activity already checked. A post to a person goes to the chat they
|
|
@@ -204,6 +257,33 @@ export function createAdapter ({ connector, receive, app, tenant }) {
|
|
|
204
257
|
...(answering === null ? {} : { replyToId: answering })
|
|
205
258
|
})
|
|
206
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
|
+
|
|
207
287
|
return {
|
|
208
288
|
...TEAMS,
|
|
209
289
|
|
|
@@ -212,12 +292,13 @@ export function createAdapter ({ connector, receive, app, tenant }) {
|
|
|
212
292
|
save = persist
|
|
213
293
|
},
|
|
214
294
|
|
|
215
|
-
listen: ({ onMessage, signal, log }) => receive({
|
|
295
|
+
listen: ({ onMessage, onChoice, signal, log }) => receive({
|
|
216
296
|
signal,
|
|
217
297
|
log,
|
|
218
298
|
onActivity: async (one) => {
|
|
219
299
|
if (tenantOf(one) !== tenant) return log(`dropped an activity from tenant ${tenantOf(one)}: this bot serves ${tenant}`)
|
|
220
300
|
remember(one)
|
|
301
|
+
if (one.type === 'invoke' && one.name === 'adaptiveCard/action' && one.value?.trigger !== 'automatic') return press(one, onChoice, log)
|
|
221
302
|
if (one.type !== 'message') return
|
|
222
303
|
if (one.conversation.conversationType === 'groupChat') return log(`group chat ${one.conversation.id}: group chats are not served yet`)
|
|
223
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`)
|
|
@@ -225,17 +306,7 @@ export function createAdapter ({ connector, receive, app, tenant }) {
|
|
|
225
306
|
}
|
|
226
307
|
}),
|
|
227
308
|
|
|
228
|
-
post:
|
|
229
|
-
if (!conversation.startsWith('19:')) {
|
|
230
|
-
const chat = await chatOf(conversation)
|
|
231
|
-
return `${chat} ${await connector.send(serviceOf(conversation), chat, activity(text, { markdown, answering }))}`
|
|
232
|
-
}
|
|
233
|
-
if (conversation.includes(THREAD)) {
|
|
234
|
-
return `${conversation} ${await connector.send(serviceOf(conversation), conversation, activity(text, { markdown, answering }))}`
|
|
235
|
-
}
|
|
236
|
-
const made = await connector.create(serviceOf(conversation), { isGroup: true, channelData: { channel: { id: conversation }, tenant: { id: tenant } }, activity: activity(text, { markdown }) })
|
|
237
|
-
return `${made.id} ${made.activityId}`
|
|
238
|
-
},
|
|
309
|
+
post: (conversation, text, { answering = null, markdown = false } = {}) => into(conversation, activity(text, { markdown, answering })),
|
|
239
310
|
|
|
240
311
|
edit: async (conversation, note, text) => {
|
|
241
312
|
const where = placeOf(note)
|
|
@@ -245,6 +316,32 @@ export function createAdapter ({ connector, receive, app, tenant }) {
|
|
|
245
316
|
remove: async (conversation, note) => {
|
|
246
317
|
const where = placeOf(note)
|
|
247
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
|
|
248
345
|
}
|
|
249
346
|
}
|
|
250
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
|
|