@rhizogrip/spore-plugin-teams 0.1.0 → 0.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.
Files changed (3) hide show
  1. package/README.md +29 -7
  2. package/package.json +3 -3
  3. package/src/adapter.js +19 -11
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**, single-tenant, whose app registration in
22
- Entra ID gives the bot file its `app` (the Microsoft App ID), its `tenant`, and a client secret,
23
- kept in `environment` under the variable `secret` names. The Azure Bot's messaging endpoint is a
24
- public HTTPS address of yours ending in `/api/messages`, which your proxy forwards to `listen` —
25
- a loopback address: the service never faces the internet itself. Turn on the Azure Bot's Teams
26
- channel, and install a Teams app for the bot in your tenant with the personal and team scopes.
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. The answer is sent as Teams
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizogrip/spore-plugin-teams",
3
- "version": "0.1.0",
3
+ "version": "0.2.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.22.0"
31
+ "@rhizogrip/spore": "^1.23.0"
32
32
  },
33
33
  "scripts": {
34
34
  "test": "vitest run"
35
35
  },
36
36
  "spore": {
37
- "works": "^1.22.0",
37
+ "works": "^1.23.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,8 +8,8 @@
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'])
@@ -22,6 +22,12 @@ const invalid = (path, problem, detail = `a bot is ${SHAPE}`) => new SporeError(
22
22
 
23
23
  const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
24
24
 
25
+ const flags = (path, kind, id, entry) => {
26
+ for (const name of ['details', 'brief']) {
27
+ if (entry[name] !== undefined && typeof entry[name] !== 'boolean') throw invalid(path, `has a ${kind} ${id} whose ${name} is not true or false`)
28
+ }
29
+ }
30
+
25
31
  const strays = (path, kind, id, entry, takes) => {
26
32
  const extra = Object.keys(entry).filter(key => !takes.includes(key))
27
33
  if (!extra.length) return
@@ -41,9 +47,10 @@ export function listenOf (raw, path) {
41
47
 
42
48
  /**
43
49
  * 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`, each
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.
50
+ * chat by their Entra object id — `"<spore>"` or `{ "spore", "details", "brief" }` — and `channels`,
51
+ * each standard channel by its id, with its one spore and who may address it there: object ids, or
52
+ * `*` for everyone in the channel. `details` shows the calls themselves rather than their kinds;
53
+ * `brief` is the bot's own, for this conversation.
47
54
  */
48
55
  function routes (raw, path) {
49
56
  for (const field of ['app', 'tenant']) {
@@ -63,28 +70,29 @@ function routes (raw, path) {
63
70
  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
71
  if (record(entry)) {
65
72
  strays(path, 'person', person, entry, PERSON_FIELDS)
66
- if (entry.details !== undefined && typeof entry.details !== 'boolean') throw invalid(path, `has a person ${person} whose details is not true or false`)
73
+ flags(path, 'person', person, entry)
67
74
  }
68
75
  const spore = record(entry) ? entry.spore : entry
69
76
  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 })
77
+ 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
78
  }
72
79
  for (const [channel, entry] of entries('channels')) {
73
80
  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
81
  if (!record(entry)) throw invalid(path, `has a channel ${channel} that is not { "spores": [...], "people": [...] }`)
75
82
  strays(path, 'channel', channel, entry, CHANNEL_FIELDS)
76
- if (entry.details !== undefined && typeof entry.details !== 'boolean') throw invalid(path, `has a channel ${channel} whose details is not true or false`)
83
+ flags(path, 'channel', channel, entry)
77
84
  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
85
  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
86
  if (!Array.isArray(entry.people) || !entry.people.every(who => who === '*' || (typeof who === 'string' && PERSON.test(who)))) {
80
87
  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
88
  }
82
- table.set(channel, { spores: entry.spores, details: entry.details === true, show: null, people: entry.people, overhear: false })
89
+ table.set(channel, { spores: entry.spores, details: entry.details === true, show: null, brief: entry.brief ?? null, people: entry.people, overhear: false })
83
90
  }
84
91
  return table
85
92
  }
86
93
 
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; /extend wall 30m gives it more, as far as its spore.cmk allows.'
94
+ 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'
95
+ const EXTEND = '/extend wall 30m gives it more, as far as its spore.cmk allows'
88
96
 
89
97
  export const TEAMS = {
90
98
  kind: 'teams',
@@ -98,7 +106,7 @@ export const TEAMS = {
98
106
  example: SHAPE,
99
107
  routes,
100
108
  routeOf: (conversation) => conversation.split(THREAD)[0],
101
- usage: { help: HELP, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
109
+ usage: { help: `${HELP}; ${EXTEND}.`, briefHelp: `${HELP}.`, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
102
110
  limits: { message: 40000, file: 0, editEveryMs: 2500, typingEveryMs: 4000 },
103
111
  can: { upload: false, remove: true, react: false, typing: false, reply: true },
104
112
  reactions: null,