@rhizogrip/spore-plugin-telegram 0.6.1 → 0.8.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
@@ -8,7 +8,7 @@ no `spore plugin allow`: `spore service` offers it once it is installed.
8
8
  npm i -g @rhizogrip/spore-plugin-telegram
9
9
  echo 'TRIAGE_BOT_TOKEN=…' >> ~/.config/spore/environment # from @BotFather; it never reaches a spore
10
10
  mkdir -p ~/.config/spore/telegram
11
- echo '{"token": "${TRIAGE_BOT_TOKEN}", "chats": {"148392011": "triage"}}' > ~/.config/spore/telegram/triage.json
11
+ echo '{"token": "${TRIAGE_BOT_TOKEN}", "people": {"148392011": "triage"}}' > ~/.config/spore/telegram/triage.json
12
12
  spore service define telegram@triage && spore service start telegram@triage
13
13
  ```
14
14
 
@@ -19,9 +19,9 @@ cannot read, or while another process reads the same bot's updates.
19
19
  ## One bot per file
20
20
 
21
21
  Each bot is a file, `~/.config/spore/telegram/<bot>.json`, and a service instance of its
22
- own, `telegram@<bot>`: its name, its avatar, its token, its chats and its state. A spore that
23
- should have a face of its own on Telegram gets its own bot. One bot may still reach several
24
- spores, and one spore may be reached through several bots. A chat of a bot is
22
+ own, `telegram@<bot>`: its name, its avatar, its token, its people and groups, and its state. A
23
+ spore that should have a face of its own on Telegram gets its own bot. One bot may still reach
24
+ several spores, and one spore may be reached through several bots. A chat of a bot is
25
25
  `telegram:<bot>/<chat id>` wherever spore names a place an answer goes. No bot's token is
26
26
  passed to a spore's runner.
27
27
 
@@ -45,8 +45,9 @@ passed to a spore's runner.
45
45
 
46
46
  ## A turn in a chat
47
47
 
48
- Write to the bot and the message goes to the spore the chat is mapped to, which is told who
49
- wrote it and where — your name and username, the private chat or the group. The bot posts
48
+ Write to the bot in private and the message goes to the spore your id is mapped to; in a group,
49
+ mention it or reply to it (below). The spore is told who wrote it and where — your name and
50
+ username, the private chat or the group. The bot posts
50
51
  `working…` and shows which tools run as they run. When the turn ends, the answer arrives as a new
51
52
  message replying to yours, so it notifies you however long the turn took, and `working…` is
52
53
  removed; one Telegram will no longer let the bot remove, past 48 hours, says `answered below`. The
@@ -93,10 +94,19 @@ A turn started with `spore send` is shown only there; no chat is told.
93
94
 
94
95
  ## The allow-list is the routing
95
96
 
96
- A bot's `chats` say which chats may talk to it and which spore each reaches, as
97
- `{ "<chat id>": "<spore>" }`. They are read on every message and followed as they change, so
98
- a chat is added, moved or removed by editing a line. It lives beside `config.json` and
99
- `sources.json`. Spores cannot read or change it.
97
+ A bot's `people` say who may talk to it in private and which spore each reaches, as
98
+ `{ "<user id>": "<spore>" }` — a private chat's id is its person's. Its `groups` say which groups
99
+ it serves, each with its spore and who there may address it:
100
+
101
+ ```json
102
+ { "token": "${TRIAGE_BOT_TOKEN}",
103
+ "people": { "148392011": "triage" },
104
+ "groups": { "-1001234567": { "spores": ["triage"], "people": ["148392011", "*"] } } }
105
+ ```
106
+
107
+ Both are read on every message and followed as they change, so a person or a group is added,
108
+ moved or removed by editing a line. The file lives beside `config.json` and `sources.json`. Spores
109
+ cannot read or change it.
100
110
 
101
111
  While a turn works, its chat is shown what kind of call ran — `reading a file`, `running a
102
112
  command`, `asking forgejo` by the source's name, a run of the same one counted, a slow one
@@ -104,12 +114,12 @@ timed, a command that exited non-zero given its exit code, a call that failed ma
104
114
  machine, and a command line or a path is yours. A chat can ask for the calls themselves:
105
115
 
106
116
  ```json
107
- { "token": "${TRIAGE_BOT_TOKEN}", "chats": { "148392011": { "spore": "triage", "details": true } } }
117
+ { "token": "${TRIAGE_BOT_TOKEN}", "people": { "148392011": { "spore": "triage", "details": true } } }
108
118
  ```
109
119
 
110
120
  It is then shown `reading data/report.md`, `running git log --oneline -5`,
111
121
  `source forgejo --path …` — the first line, cut short. Choose it for a chat you would let
112
- read the commands; the others keep the kinds.
122
+ read the commands; the others keep the kinds. A group takes `"details": true` the same way.
113
123
 
114
124
  A chat can be shown a turn more quietly, which is what a group gets unless its entry says
115
125
  otherwise: `"show": "react"` puts a 👨‍💻 on your message when the spore takes it and shows Telegram's
@@ -120,7 +130,7 @@ Telegram lets a bot react with (✅ and ❌ are not among them; the log lists th
120
130
 
121
131
  ```json
122
132
  { "token": "${OPS_BOT_TOKEN}", "react": { "taken": "👀", "failed": "🤷" },
123
- "chats": { "-1001234567": { "spore": "triage", "show": "react" } } }
133
+ "people": { "148392011": { "spore": "triage", "show": "react" } } }
124
134
  ```
125
135
 
126
136
  Two chats may point at one spore and then share its conversation. A reply goes to the
@@ -129,13 +139,41 @@ chat that asked; a turn nobody in a chat started goes to the spore's default.
129
139
  A chat not in the file gets silence, and the log gets the line to add:
130
140
 
131
141
  ```
132
- refused chat 148392011 (@alice) — to allow it, add "148392011": "<a spore>" to the chats of …/telegram/triage.json
142
+ refused chat 148392011 (@alice) — to allow it, add "148392011": "<a spore>" to the people of …/telegram/triage.json
143
+ ```
144
+
145
+ which is also how you learn your own user id: start with `"people": {}` and message the bot. A
146
+ group the file does not name is told nothing either, when someone there mentions the bot; the log
147
+ says the `groups` entry to add, its id negative. Where a spore's `replyTo` default is one of this
148
+ bot's chats, that chat is told the same line, once per chat the file does not name, so you hear of
149
+ someone trying the bot without reading its log.
150
+
151
+ ## A group
152
+
153
+ A group has one spore — in a group the bot speaks as itself, so a second spore there is a second
154
+ bot — and `people`, who may address it: user ids, or `"*"` for everyone in the group, written out.
155
+ A message reaches the spore when it mentions the bot (`@triage_bot what failed?`), replies to one of
156
+ its messages, or is a command for it — `/status`, or `/status@triage_bot` where several bots share
157
+ the group; a command naming another bot is not seen. Anything else said there is not for the
158
+ spore, and the bot's mention is not part of what the spore is told. Someone the entry does not list
159
+ who mentions the bot gets silence, and the log gets the line that would let them in.
160
+
161
+ `/status` is for anyone listed; `/stop`, `/wrap` and `/extend` are for the people the entry names
162
+ by id — not everyone `"*"` lets talk — and anyone else is told so.
163
+
164
+ A group can let its spore hear what is said there that is not to it:
165
+
166
+ ```json
167
+ "groups": { "-1001234567": { "spores": ["triage"], "people": ["148392011", "209876543"], "overhear": true } }
133
168
  ```
134
169
 
135
- which is also how you learn your own chat id: start with `"chats": {}` and message the bot. A
136
- group's id is negative, and it is the group you add rather than the people in it. Where a spore's
137
- `replyTo` default is one of this bot's chats, that chat is told the same line, once per chat the
138
- file does not name, so you hear of someone trying the bot without reading its log.
170
+ What the people it lists say in the group then reaches the spore as overheard: no turn starts for
171
+ it and no answer is owed, and the next turn the spore runs reads it as what was said around it, the
172
+ latest 100 messages at a time. It is everything they say there, kept in the spore's records and
173
+ read by its next turn, so choose it for a group whose talk the spore should follow. Telegram gives a
174
+ bot what is not said to it only with its privacy mode off — `/setprivacy` in @BotFather, then the
175
+ bot added to the group again — or as an admin of the group; with neither, the service says so for
176
+ each group that overhears when it starts.
139
177
 
140
178
  ## What it keeps
141
179
 
@@ -14,6 +14,6 @@ await runService({
14
14
  open: async ({ bot, credentials }) => {
15
15
  const telegram = createTelegram(credentials.token)
16
16
  const me = await telegram.me()
17
- return { adapter: createAdapter({ telegram, bot }), who: `@${me.username}` }
17
+ return { adapter: createAdapter({ telegram, bot, me }), who: `@${me.username}` }
18
18
  }
19
19
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizogrip/spore-plugin-telegram",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Telegram for spores: a bot of yours in chats, each chat reaching its spore, run as a service",
5
5
  "author": {
6
6
  "name": "Christophe Le Bars",
@@ -31,13 +31,13 @@
31
31
  "marked": "^18.0.11"
32
32
  },
33
33
  "peerDependencies": {
34
- "@rhizogrip/spore": "^1.19.0"
34
+ "@rhizogrip/spore": "^1.21.0"
35
35
  },
36
36
  "scripts": {
37
37
  "test": "vitest run"
38
38
  },
39
39
  "spore": {
40
- "works": "^1.19.0",
40
+ "works": "^1.21.0",
41
41
  "services": {
42
42
  "telegram": {
43
43
  "what": "a bot of yours in Telegram chats, each chat reaching its spore",
package/src/adapter.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Telegram for the relay (`@rhizogrip/spore/relay`): the bot file it reads, the long poll that
3
- * hears chats, and the Bot API calls that answer them. A chat is a conversation, keyed by its id;
4
- * the bot file maps each to the spore it reaches.
3
+ * hears chats, and the Bot API calls that answer them. A chat is a conversation, keyed by its id:
4
+ * a private chat's is its person's, under `people`, and a group's is negative, under `groups`.
5
5
  */
6
6
 
7
7
  import { SporeError } from '@rhizogrip/spore'
@@ -11,7 +11,8 @@ import { toHtml } from './format.js'
11
11
  const COMMAND = /^\/([a-z]+)(@\w+)?(?:\s+(.+))?$/is
12
12
  const NOT_MODIFIED = /message is not modified/
13
13
  const CANNOT_PARSE = /can't parse entities/i
14
- const SHAPE = '{ "token": "${TRIAGE_BOT_TOKEN}", "chats": { "<chat id>": "<spore>" } }' // eslint-disable-line no-template-curly-in-string
14
+ const SHAPE = '{ "token": "${TRIAGE_BOT_TOKEN}", "people": { "<your user id>": "<spore>" }, "groups": { "<group id>": { "spores": ["<spore>"], "people": ["<user id>"] } } }' // eslint-disable-line no-template-curly-in-string
15
+ const VERBS = new Set(['status', 'stop', 'wrap', 'extend', 'help'])
15
16
 
16
17
  /** What a bot may react with: `ReactionTypeEmoji` in the Bot API. */
17
18
  const REACTIONS = [
@@ -25,40 +26,75 @@ const REACTIONS = [
25
26
  '🤷', '🤷‍♀', '😡'
26
27
  ]
27
28
 
28
- const FIELDS = ['token', 'chats', 'react']
29
- const CHAT_FIELDS = ['spore', 'details', 'show']
29
+ const FIELDS = ['token', 'people', 'groups', 'react']
30
+ const PERSON_FIELDS = ['spore', 'details', 'show']
31
+ const GROUP_FIELDS = ['spores', 'people', 'details', 'show', 'overhear']
32
+ const PERSON = /^\d+$/
33
+ const GROUP = /^-\d+$/
30
34
 
31
35
  const said = (err) => [err.message, err.detail].filter(Boolean).join(' — ')
32
36
 
33
- const invalid = (path, problem, detail) => new SporeError(`${path} ${problem}`, { type: 'TELEGRAM_BOT_INVALID', detail })
37
+ const invalid = (path, problem, detail = `a bot is ${SHAPE}`) => new SporeError(`${path} ${problem}`, { type: 'TELEGRAM_BOT_INVALID', detail })
38
+
39
+ const strays = (path, kind, id, entry, takes) => {
40
+ const extra = Object.keys(entry).filter(key => !takes.includes(key))
41
+ if (!extra.length) return
42
+ const bots = extra.filter(key => FIELDS.includes(key))
43
+ throw invalid(path, `gives ${kind} ${id} ${extra.join(', ')}, which a ${kind} does not take`, `a ${kind} takes ${takes.join(', ')}${bots.length ? `; ${bots.join(', ')} is the bot's, beside people and groups` : ''}`)
44
+ }
45
+
46
+ const flags = (path, kind, id, entry, names) => {
47
+ for (const name of names) {
48
+ if (entry[name] !== undefined && typeof entry[name] !== 'boolean') throw invalid(path, `has a ${kind} ${id} whose ${name} is not true or false`)
49
+ }
50
+ }
34
51
 
35
52
  /**
36
- * `{ "<chat id>": "<spore>" }`, or `{ "<chat id>": { "spore": "<spore>", "details": true, "show":
37
- * "react" } }`: `details` for a chat shown what each call does — its command or path — rather than
38
- * only what kind of call it is, since a Telegram message leaves the machine; `show` for how a turn
39
- * shows itself there.
53
+ * The private chats, `people: { "<person id>": "<spore>" }` or `{ "spore", "details", "show" }`,
54
+ * and the groups, `groups: { "<group id>": { "spores": ["<spore>"], "people": [...], "details",
55
+ * "show", "overhear" } }`. `details` shows a chat what each call does — its command or path — rather
56
+ * than only what kind of call it is, since a Telegram message leaves the machine; `show` how a turn
57
+ * shows itself there. A group names who may address its spore — user ids, or `*` for everyone in it
58
+ * — and has one spore: in a group a bot speaks as itself, so a second spore is a second bot.
59
+ * `overhear` gives the spore what they say there that is not to it.
40
60
  */
41
61
  function routes (raw, path) {
42
- if (typeof raw.chats !== 'object' || raw.chats === null || Array.isArray(raw.chats)) {
43
- throw invalid(path, 'has no chats', 'chats is { "<chatId>": "<spore>" } — {} while you learn your chat id: the log names every chat it refuses')
62
+ if (raw.people === undefined && raw.groups === undefined) {
63
+ throw invalid(path, 'has no people and no groups', `a bot is ${SHAPE} — "people": {} while you learn your user id: the log names every chat it refuses`)
64
+ }
65
+ const entries = (field) => {
66
+ const value = raw[field] ?? {}
67
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw invalid(path, `has ${field} that is not { "<id>": … }`)
68
+ return Object.entries(value)
44
69
  }
45
70
  const table = new Map()
46
- for (const [chat, entry] of Object.entries(raw.chats)) {
47
- const spore = typeof entry === 'string' ? entry : entry?.spore
48
- const extra = typeof entry === 'object' && entry !== null ? Object.keys(entry).filter(key => !CHAT_FIELDS.includes(key)) : []
49
- if (extra.length) {
50
- const bots = extra.filter(key => FIELDS.includes(key))
51
- throw invalid(path, `gives chat ${chat} ${extra.join(', ')}, which a chat does not take`, `a chat takes ${CHAT_FIELDS.join(', ')}${bots.length ? `; ${bots.join(', ')} is the bot's, beside chats` : ''}`)
71
+ for (const [person, entry] of entries('people')) {
72
+ if (!PERSON.test(person)) throw invalid(path, `names "${person}" among its people, which is not a person's id`, 'a person\'s id is a positive number; a group\'s is negative, and goes under groups')
73
+ const shaped = typeof entry === 'object' && entry !== null && !Array.isArray(entry)
74
+ if (shaped) {
75
+ strays(path, 'person', person, entry, PERSON_FIELDS)
76
+ flags(path, 'person', person, entry, ['details'])
52
77
  }
53
- if (typeof spore !== 'string' || !spore || (typeof entry === 'object' && entry.details !== undefined && typeof entry.details !== 'boolean')) {
54
- throw invalid(path, `maps ${chat} to something that is not a spore`, 'each chat is a chat id against the name of a spore it may reach, or { "spore": "<name>", "details": true, "show": "react" }')
78
+ const spore = shaped ? entry.spore : entry
79
+ if (typeof spore !== 'string' || !spore) throw invalid(path, `maps person ${person} to something that is not a spore`, 'each person is their id against the name of the spore they reach, or { "spore": "<name>", "details": true, "show": "react" }')
80
+ table.set(person, { spores: [spore], details: shaped && entry.details === true, show: shaped ? entry.show ?? null : null, people: null, overhear: false })
81
+ }
82
+ for (const [group, entry] of entries('groups')) {
83
+ if (!GROUP.test(group)) throw invalid(path, `names "${group}" among its groups, which is not a group's id`, 'a group\'s id is negative (-100… for a supergroup); a person\'s goes under people')
84
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) throw invalid(path, `has a group ${group} that is not { "spores": [...], "people": [...] }`)
85
+ strays(path, 'group', group, entry, GROUP_FIELDS)
86
+ flags(path, 'group', group, entry, ['details', 'overhear'])
87
+ if (!Array.isArray(entry.spores) || !entry.spores.length || !entry.spores.every(spore => typeof spore === 'string' && spore)) throw invalid(path, `has a group ${group} with no spores`)
88
+ if (entry.spores.length > 1) throw invalid(path, `gives group ${group} ${entry.spores.length} spores`, 'a bot speaks in a group as itself, so a group has one spore — a second spore there is a second bot')
89
+ if (!Array.isArray(entry.people) || !entry.people.every(who => who === '*' || (typeof who === 'string' && PERSON.test(who)))) {
90
+ throw invalid(path, `has a group ${group} whose people are not user ids or *`, 'people is who may address its spore — ["<user id>", …], or ["*"] for everyone in the group, written out')
55
91
  }
56
- table.set(chat, { spores: [spore], details: typeof entry === 'object' && entry.details === true, show: typeof entry === 'object' ? entry.show ?? null : null })
92
+ table.set(group, { spores: entry.spores, details: entry.details === true, show: entry.show ?? null, people: entry.people, overhear: entry.overhear === true })
57
93
  }
58
94
  return table
59
95
  }
60
96
 
61
- const HELP = 'Anything you write here goes to the spore. /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.'
97
+ const HELP = 'Anything you write to me in private goes to the spore; in a group, mention me or reply to 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.'
62
98
 
63
99
  export const TELEGRAM = {
64
100
  kind: 'telegram',
@@ -73,47 +109,78 @@ export const TELEGRAM = {
73
109
  routes,
74
110
  usage: { help: HELP, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
75
111
  limits: { message: 4096, file: 50 * 1024 * 1024, editEveryMs: 2500, typingEveryMs: 4000 },
76
- can: { upload: true, remove: true, react: true, typing: true },
112
+ can: { upload: true, remove: true, react: true, typing: true, reply: true },
113
+ showShared: 'react',
77
114
  reactions: REACTIONS,
78
115
  hint (err, { file, bot }) {
79
116
  if (err.type === 'TELEGRAM_NO_BOT') {
80
117
  const variable = `${bot.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_BOT_TOKEN`
81
- return `mkdir -p ${file.slice(0, file.lastIndexOf('/'))} && echo '{"token": "$\{${variable}}", "chats": {}}' > ${file}\n with chats {}, start the bot and message it — the log names every chat it refuses, with its id`
118
+ return `mkdir -p ${file.slice(0, file.lastIndexOf('/'))} && echo '{"token": "$\{${variable}}", "people": {}}' > ${file}\n with people {}, start the bot and message it — the log names every chat it refuses, with its id`
82
119
  }
83
120
  if (err.type === 'TELEGRAM_FAILED') return `the token comes from @BotFather, in the variable ${file} names`
84
121
  if (err.type === 'TELEGRAM_CONFLICT') return 'one bot token is read by one process — stop the other one, or remove its webhook'
85
122
  return null
86
123
  },
87
- refusal: (message, botFile) =>
88
- `refused chat ${message.conversation}${message.person.username ? ` (@${message.person.username})` : ''} — to allow it, add "${message.conversation}": "<a spore>" to the chats of ${botFile}`
124
+ refusal: (message, botFile) => message.shared
125
+ ? `refused ${message.where}, chat ${message.conversation} — to allow it, add "${message.conversation}": { "spores": ["<a spore>"], "people": ["${message.person.id}"] } to the groups of ${botFile}`
126
+ : `refused chat ${message.conversation}${message.person.username ? ` (@${message.person.username})` : ''} — to allow it, add "${message.conversation}": "<a spore>" to the people of ${botFile}`
89
127
  }
90
128
 
91
129
  const FILES = ['photo', 'document', 'video', 'audio', 'voice', 'video_note', 'animation', 'sticker']
92
130
 
93
- export function normalize (update, bot) {
131
+ /** Whether the bot's own `@username` is among what Telegram marked as mentions. */
132
+ const mentions = (message, me) => {
133
+ const source = message.text ?? message.caption ?? ''
134
+ const own = `@${me.username}`.toLowerCase()
135
+ return (message.entities ?? message.caption_entities ?? [])
136
+ .some(entity => entity.type === 'mention' && source.slice(entity.offset, entity.offset + entity.length).toLowerCase() === own)
137
+ }
138
+
139
+ /**
140
+ * An update as the relay takes it, or null for none it takes: what is not a chat message, and a
141
+ * command naming another bot. In a group, a message reaches the spore when it mentions the bot,
142
+ * replies to one of its messages, or is a command for it — one of the relay's verbs, or one naming
143
+ * this bot; anything else names no spore (`addresses: []`). The bot's mention is not part of what
144
+ * was said to the spore.
145
+ */
146
+ export function normalize (update, bot, me) {
94
147
  const message = update.message
95
148
  if (message?.chat?.id === undefined) return null
96
- const text = (message.text ?? message.caption ?? '').trim() || null
97
- const command = text?.match(COMMAND)
149
+ const written = (message.text ?? message.caption ?? '').trim() || null
150
+ const command = written?.match(COMMAND)
151
+ if (command?.[2] && command[2].slice(1).toLowerCase() !== me.username.toLowerCase()) return null
152
+ const shared = message.chat.type !== 'private'
153
+ const addressed = !shared || Boolean(command && (command[2] || VERBS.has(command[1].toLowerCase()))) ||
154
+ mentions(message, me) || message.reply_to_message?.from?.id === me.id
155
+ const text = command ? written : written?.replace(new RegExp(`@${me.username}\\b[,:]?\\s*`, 'gi'), '').trim() || null
98
156
  const { first_name: first, last_name: last, username } = message.from
99
157
  return {
100
158
  id: `${bot}:${update.update_id}`,
101
159
  conversation: String(message.chat.id),
102
160
  message: message.message_id,
103
- person: { id: String(message.from.id), name: `${[first, last].filter(Boolean).join(' ')}${username ? ` (@${username})` : ''}`, username },
104
- where: message.chat.type === 'private' ? 'a private chat on Telegram' : `the Telegram group "${message.chat.title}"`,
105
- shared: message.chat.type !== 'private',
161
+ person: { id: String(message.from.id), name: `${[first, last].filter(Boolean).join(' ')}${username ? ` (@${username})` : ''}`, username, groups: [] },
162
+ spore: null,
163
+ where: shared ? `the Telegram group "${message.chat.title}"` : 'a private chat on Telegram',
164
+ shared,
106
165
  text,
107
166
  command: command ? { verb: command[1].toLowerCase(), args: command[3] ?? '' } : null,
108
- addresses: null,
167
+ addresses: addressed ? null : [],
109
168
  files: FILES.filter(kind => message[kind]).length
110
169
  }
111
170
  }
112
171
 
113
- export function createAdapter ({ telegram, bot }) {
172
+ export function createAdapter ({ telegram, bot, me }) {
114
173
  return {
115
174
  ...TELEGRAM,
116
175
 
176
+ /** Each group that overhears, said when the bot gets only what is addressed to it. */
177
+ prepare: async (table, log) => {
178
+ if (me.can_read_all_group_messages) return
179
+ for (const [group, { overhear }] of table) {
180
+ if (overhear) log(`group ${group} overhears, and @${me.username} gets only what is said to it there: turn its privacy mode off with /setprivacy in @BotFather and add it to the group again, or make it an admin of the group`)
181
+ }
182
+ },
183
+
117
184
  /**
118
185
  * One long poll after another until `signal` aborts. An update is handed to `onMessage` and
119
186
  * awaited before the offset moves past it, since moving it is what confirms the update to
@@ -139,7 +206,7 @@ export function createAdapter ({ telegram, bot }) {
139
206
  continue
140
207
  }
141
208
  for (const update of updates) {
142
- const message = normalize(update, bot)
209
+ const message = normalize(update, bot, me)
143
210
  if (message) await onMessage(message)
144
211
  offset = update.update_id + 1
145
212
  }