Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 20. října 2026

Procházet dokumentaci · Microsoft 365 operations

Tento článek zatím není přeložen. Je dostupný v angličtině.

Developer reference

msteams — operations

Running the plugin in production: Azure setup, what each config field actually changes, failure modes and what they look like in the log. The code walkthrough is in architecture.md; the config reference and file map are in maintainer.md.


1. Azure app registration

The click-path is carried in the manifest itself, as the sec_connection section help in elowen-plugin.json, so the Settings UI shows it next to the fields:

  1. Entra admin center → App registrations → New registration, single tenant. Copy the Application (client) ID → appId, and the Directory (tenant) ID → tenantId.
  2. Certificates & secrets → New client secret → appPassword.
  3. Azure portal → Create an Azure Bot bound to that app id. Set the messaging endpoint to https://<your-domain>/hooks/msteams/messages and enable the Microsoft Teams channel.

The plugin authenticates outbound calls as this app registration, against https://login.microsoftonline.com/<tenantId>/oauth2/v2.0/token for the https://api.botframework.com/.default scope (TokenSource in lib/token.mjs, SCOPE in lib/connector.mjs).

Inbound, it verifies Microsoft's JWT with the audience pinned to appId and the issuer pinned to https://api.botframework.com (makeTokenVerifier in lib/auth.mjs). The issuer being the connector service and not your tenant is correct and deliberate — the tenant-scoped issuer applies to user tokens, never to service-to-bot callbacks (see the comment at the top of lib/auth.mjs).

2. The Teams app package

The plugin builds the sideloadable package itself from the live config — there is no file to maintain.

  • Served at GET /plugins/msteams/app-package, admin-only, as elowen-teams-app.zip by default, or <agentName>-teams-app.zip with the name slugged (register in index.mjs).
  • The route is registered before the credential check on purpose (register in index.mjs): while the plugin is enabled but unconfigured it answers 503, not 404. Registering it after the gate would make an enabled-but-unconfigured instance look like a missing route.
  • Contents (buildAppPackage in lib/appPackage.mjs): manifest.json, a 192 px colour icon and a 32 px white outline.png, in a hand-rolled stored (uncompressed) ZIP. The colour icon is the PNG named by appIconPath when set, otherwise a solid Teams-purple square.
  • The Teams manifest (appManifest in lib/appPackage.mjs, schema version in MANIFEST_VERSION) sets id and botId to your appId, scopes the bot to personal, team and groupChat, declares supportsFiles: true and supportsTargetedMessages: true, sets validDomains (token.botframework.com only when personal-chat sign-in is on, otherwise empty) and the permissions identity and messageTeamMembers. The command lists take the first twelve entries of helpCommands(), the host's live chat commands plus the synthetic display entry that ADAPTER_STATE_COMMANDS adds. Team and group chats offer them in the / menu and after an @mention; personal chats offer them after an @mention only. The same list backs /help.
  • The package version is a UTC build stamp (packageVersion). Teams refuses an upload whose version is not higher than the one already in the catalog, so every download carries a newer number.

Upload it in Teams admin center → Manage apps. tests/msteams.test.ts verifies the ZIP framing, the manifest id/botId/scopes/icons and that both PNGs are really there.

3. The webhook route

register in index.mjs mounts messages, which the daemon serves at /hooks/msteams/messages. The bearer-auth layer treats /hooks/* as public (src/api/auth.ts) — the plugin authenticates every request itself.

Your reverse proxy must forward /hooks/ to the daemon port, before the catch-all / location. The managed nginx vhost that elowen setup writes already does (src/cli/install/proxy.ts), and the config health check reports <vhost> does not route /hooks/ with a fix hint when it does not (src/api/routes/system.ts).

Symptom of a missing proxy rule: the Azure Bot channel health check fails and nothing at all appears in the daemon log — the request never reaches the plugin.

4. What the config fields do at runtime

The full table is in maintainer.md. Operationally, the ones with consequences beyond cosmetics:

  • rolePolicies is the access boundary. First match wins, and a sender matching no policy is ignored in complete silence — no reply, no log line, no outbound traffic (the accessFor check in handleActivity). If "the bot ignores me" is the report, this is almost always the cause.
    • A roleId may be an Entra object ID (a GUID), a UPN/e-mail, or a whole conversation id, which grants everyone in that chat. Matching is case-insensitive whenever either side contains an @ (sameId in lib/ids.mjs) — so UPNs, e-mails and conversation ids like 19:…@thread.tacv2 are all case-insensitive; only GUIDs and 29: account ids must match exactly.
    • For a team channel, either the full 19:…@thread.tacv2;messageid=… form or the bare 19:…@thread.tacv2 from a Teams deep link works (senderIds in lib/ids.mjs).
    • admin: true marks the operator: it unlocks /model, /reasoning, /display, /context, /stats, /compact and /stop, lets that person act on a card opened by someone else, and drives senderIsAdmin (lib/ids.mjs).
  • respondWithoutMention only affects non-personal conversations. A personal chat always answers (the mention gate in handleActivity). A team-channel post reaches the bot without a mention only when channel message consent is active (see permissions.md). Otherwise Teams delivers a team-channel post only when the bot is @mentioned, and this setting does not change that.
  • historyLimit is the only field that makes the plugin persist message text to disk. 0 (the default) writes nothing at all. Lowering it takes effect on the next recorded message, which trims the stored list to the new limit rather than leaving a tail a later raise would resurrect (recordHistory in lib/transcript.mjs).
  • language changes the bot's own service texts only (errors, command replies); the agent answers in the user's language regardless. Note the coverage is uneven: the service texts exist in all three languages (lib/messages.mjs), but Adaptive Card button labels are switched by a single language === 'cs' boolean (the cs flag passed to buildAskCard and buildPickerCard in lib/cards.mjs, set in TeamsAsks and TeamsCommands), so with language: 'sk' the buttons ("Submit", "➤ Send custom answer", "‹ Prev", "Next ›") come out in English.
  • toolActivity / answerMode / toolOutput / toolMessageMode are global defaults; each is overridable per chat with /display. The /display card has a default option on every axis, which removes that axis's override (updateDisplayOverrides, used by TeamsCommands).

Per-channel / per-role policy in practice

Access is per sender, not per channel — but a policy whose roleId is a conversation id applies to the whole room, which is how a channel-wide policy is expressed. The matched policy contributes the role name, the extra role prompt and the admin flag; the conversation's own state contributes the model and the reasoning level (elowen-plugin-shared/access.mjs, buildRoleAccess). Project and tool scope are not part of that descriptor at all — they come from the linked account.

admin: true in a role policy grants the operator role inside the chat for platform behavior such as slash commands. It does not decide which Teams* tools the person may call. Tool availability comes from the linked account's plugin grants and per-user tool deny-list in the users modal.

5. The notify (cron) target

notifyConversationId is where a scheduled job's result lands when the job names no channel of its own. A scheduled job may override it with its own notifyChannelId.

Either value may be:

  • a conversation id the bot has already seen, or
  • a person — e-mail, Entra object ID, 29:… account id or display name — whose 1:1 chat the plugin opens and then remembers (notifyConversationFor in lib/proactive.mjs).

Every way a push can be dropped has its own warning:

Log line (all warn) Cause
msteams notify: no target — set the plugin's notifyConversationId, or give the job a notifyChannelId … (notify in lib/proactive.mjs) Neither the job nor the config named anywhere.
msteams notify: no serviceUrl known yet — send the bot one message first (notify) The bot has never received an activity, so there is no service host to send through.
msteams notify: "<x>" matches N people — name the person exactly or use their e-mail; the push was dropped (notifyConversationFor) An ambiguous display name. It is refused, never guessed.
msteams notify: could not open a conversation with <x> (notifyConversationFor) Teams accepted the create call but returned no conversation id.
msteams notify: could not reach <x>: … (notifyConversationFor) Resolving or opening the conversation threw (see §6).

An explicit destination:msteams: envelope does not drop silently. It rejects instead, so the consumer can keep its notice pending and retry.

6. The optional Microsoft Graph layer

Off by default. With graphLookup unset or false, no GraphClient is constructed at all (the adapter constructor in lib/adapter.mjs), so no directory Graph call is even possible — tests/msteams.test.ts asserts zero outbound requests on an unknown e-mail in that state.

Layer 1 (the people directory) needs no Microsoft permission whatsoever: it is assembled from inbound activities and conversation rosters the bot already reads. Turn Graph on only when you need the bot to write to people it has never met.

Every application Graph request, including channel history and profile photos, has a 15-second per-request deadline covering both response headers and the complete response body. The client uses AbortSignal.timeout and does not retry. Token requests for both Graph and the Bot Connector have their own 15-second deadline, including successful JSON and failed-request diagnostic bodies. The Graph request's deadline starts after its token is obtained, so these are separate budgets, not a whole-turn limit. An aborted token refresh releases the shared in-flight promise; a later caller can refresh again.

A channel-history timeout follows the existing recorded-history fallback, logging the Graph failure once per adapter. Directory lookup, app installation and photo timeouts reject through their existing caller error handling. Completed Graph 401/403 responses still report application consent instructions, while optional photo refusals keep their avatar fallback and five-minute permission cooldown. The delegated Microsoft 365 client keeps its own request deadlines and refusal handling.

What a Microsoft admin must grant

This section covers the app-only Graph layer alone. For the full picture — the four separate consent surfaces, the delegated Microsoft 365 scopes, resource-specific consent and a per-tenant setup checklist — see permissions.md.

Both are application permissions on the bot's app registration, and both need tenant admin consent:

Permission Needed for
User.ReadBasic.All Resolving an e-mail to a tenant user (findUser in lib/graph.mjs).
TeamsAppInstallation.ReadWriteSelfForUser.All Installing the Teams app for that user so a 1:1 chat can be opened (installApp in lib/graph.mjs).

Path: Entra admin center → App registrations → the bot's app → API permissions → Add a permission → Microsoft Graph → Application permissions → the permission → Grant admin consent for the tenant. The plugin quotes exactly this back at you when Graph answers 401/403, instead of forwarding a bare status (consentHelp and GraphClient.call in lib/graph.mjs). Pinned by tests/msteams.test.ts.

Additionally, installing the app for a user requires graphCatalogAppId — the app's id in the org Teams catalog (Teams admin center → Manage apps → the uploaded Elowen app), which is not the bot's appId. Leave it empty only when the app is already deployed to users by Teams app policy; the plugin then skips the install and opens the chat directly (findPersonViaGraph in lib/proactive.mjs).

Graph failure modes, all translated into an instruction rather than a status code:

An e-mail is resolved in two steps (findUser in lib/graph.mjs): a direct GET /users/{address} first, which covers the common case where the e-mail is the UPN, then a $filter search on mail or userPrincipalName for users whose mail differs. An install that answers 409 (the user already has the app) counts as success (installApp).

Situation What the operator sees
401/403 on any call The permission name plus the full consent path (GraphClient.call).
No such address in the tenant Microsoft Graph found no user with the address "…" in this tenant. (findPersonViaGraph in lib/proactive.mjs)
Address matches several users Refused, with a suggestion to address the person by Entra object id (findUser).
graphCatalogAppId empty when an install is needed An explicit instruction naming the Teams admin center column (installApp).
Catalog id not found (404) The app must be uploaded and approved in the org catalog first (installApp).

7. Common failures and what they look like in the log

Log line Level Meaning / what to do
enabled but appId/appPassword/tenantId are not all configured — not connecting (register in index.mjs) warn The plugin loaded but registered no platform, no webhook and no tools. Fill in all three.
msteams platform registered (webhook /hooks/msteams/messages + Teams and delegated Microsoft 365 tools) (register) info Registration succeeded.
msteams webhook locally available (app <appId>); provider transport unknown (connect in lib/adapter.mjs) info Local credential initialization succeeded; this is not a provider connectivity/health observation.
msteams credential check failed: … (connect) warn The token call failed at enable time — usually a wrong secret or tenant id. Initialization rejects and readiness stays unknown; no successful connection is claimed. Inbound JWT validation retains its separate boundary.
webhook token rejected: … (makeTokenVerifier in lib/auth.mjs) warn An inbound activity failed JWT validation → 401. Wrong appId, a clock far out of the 300 s tolerance, an unreachable JWKS, or a spoofed/replayed serviceUrl.
msteams turn failed in <conv>: <stack> (runTurn log in handleActivity) error The brain turn threw. The same message is also replied into the chat as ⚠️ ….
msteams turn failed: … (handleWebhook) error The detached webhook work rejected outside the per-turn catch — a card action, or a failure before onActivity reached its own try/catch.
msteams send: no stored route for conversation <id> (tmSend) warn No ref.serviceUrl for that conversation and no _meta.serviceUrl either. The bot must receive one activity before it can send.
msteams send failed: … / msteams edit failed: … (tmSend, tmEdit) error / warn A Connector call failed. The message body carries the HTTP status and the first 300 characters of Microsoft's response (ConnectorClient.call).
msteams roster lookup failed for <id>: … (roster) warn Membership could not be refreshed; the previous roster is reused, so mentions still resolve for up to 5 minutes.
image download failed: … (collectMedia) error An inbound image exceeded maxImageBytes or the download failed; the turn continues with [Attachment: image (download failed or too large)].
image upload failed: … (sendImages) error An outbound image Connector send failed. The callback logs msteams.file_upload_failed and rejects with the original cause to the shared finalizer/turn owner; notify stops before final result text. No upload replay is added.
msteams shared file offer failed for <name>: … (offerSharedFiles in lib/files.mjs) error An actual consent-card offer failed in a 1:1 chat. The original cause rejects the callback and stops later files; earlier confirmed offers stay valid. The canonical turn owner handles failure without a second local chat notice or replay. Declared size-cap notices still permit later eligible files; unsupported scopes remain no-ops.
msteams file upload failed for <name>: … (onFileConsent in lib/files.mjs) error The recipient accepted an offer and the upload to their OneDrive URL failed; the card is replaced with ⚠️ <name> could not be uploaded. Please try again.
msteams directory: could not persist <key>: … (PeopleDirectory.remember) warn The people directory could not be written. Harmless in itself — it is re-learned from the next roster read.
stateStore: failed to persist … (elowen-plugin-shared/stateStore) error The state file could not be written; this one is re-thrown, so the command that caused it fails visibly rather than confirming a change that never stuck.

Two quiet failure modes with no log line at all, worth knowing about:

  • An unmapped sender is dropped silently by the admission check in handleActivity. Check rolePolicies.
  • In a group chat with respondWithoutMention: false, a message with no bot mention returns at the mention gate in handleActivity.

Secrets never appear in any of this: the token layer echoes the response of a failed token call and never the request that carried the secret (TokenSource.token), and tests/msteams.test.ts asserts the app password is absent from tool output, the log, the persisted state file and every outbound connector payload.

8. What a redeploy or config save does — and does not — change

Saving plugin configuration persists it through the host revision boundary and requests a daemon restart. The adapter keeps its current configuration until that restart completes. A saved change with pending activation must not be presented as already effective.

Changes / is rebuilt:

  • The adapter instance and its whole config snapshot — register spreads ctx.config (normalized by normalizeConfig) once, so a field change only takes effect on the next daemon start.
  • Whether the directory Graph layer exists at all (graphLookup is read once, in the adapter constructor).
  • Every in-memory cache: the connector and Graph bearers, the JWKS handle, upnCache, rosterCache.
  • Open interactive cards stop working. pendingAsks and pendingPickers are in-memory, so a button tapped after a reload finds no pending entry and is ignored (onAskAction in lib/asks.mjs, onPickerAction in lib/commands.mjs). The card is left visibly un-settled.

Does not change:

  • channel-state.json and everything in it: conversation routes (ref), /new generations, per-chat model / reasoning / display overrides, the transcript, and the whole _people directory. A restart loses no person and no opened 1:1 chat.
  • Azure-side state: the app registration, the bot resource, the messaging endpoint, the uploaded Teams app package. Nothing in a redeploy touches those.
  • The webhook URL. It is derived from the plugin name, not from config.

A redeploy that changes the plugin's data directory (the plugin data root is derived from the daemon's data directory, see src/daemon/brainCore.ts) would orphan channel-state.json, and the bot would then have no route to any conversation until it receives a message again.

9. The agentName caveat

register in index.mjs resolves the configured agent and product names once: non-empty values are trimmed, agentName defaults to Elowen, and productName defaults to the resolved agent name. The transcript label, help heading, generated manifest and package filename consume those values.

The plugin does not read the instance brand. Nothing in the plugin calls ctx.brand(), so a renamed instance still calls itself "Elowen" in Teams until an administrator fills in this setting. Setting the value is the only way to rename the bot.