shade-plugins-ms365 0.1.0__py3-none-any.whl
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.
- shade_plugins_ms365/__init__.py +934 -0
- shade_plugins_ms365/_cliente.py +320 -0
- shade_plugins_ms365/erros.py +40 -0
- shade_plugins_ms365-0.1.0.dist-info/METADATA +102 -0
- shade_plugins_ms365-0.1.0.dist-info/RECORD +8 -0
- shade_plugins_ms365-0.1.0.dist-info/WHEEL +4 -0
- shade_plugins_ms365-0.1.0.dist-info/entry_points.txt +2 -0
- shade_plugins_ms365-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
"""O plugin ``ms365``: ler e-mails e baixar anexos do Outlook, enviar e-mail, e listar, baixar e
|
|
2
|
+
enviar arquivos do SharePoint, pela Microsoft Graph, sob o contrato de plugin do shade-sdk, com o
|
|
3
|
+
token OAuth da conexão da plataforma.
|
|
4
|
+
|
|
5
|
+
```toml
|
|
6
|
+
[hosts]
|
|
7
|
+
permitidos = ["graph.microsoft.com", "contoso.sharepoint.com"]
|
|
8
|
+
|
|
9
|
+
[conexoes]
|
|
10
|
+
nomes = ["microsoft-365"]
|
|
11
|
+
|
|
12
|
+
[config]
|
|
13
|
+
sharepoint_host = "contoso.sharepoint.com" # só para baixar arquivo do SharePoint
|
|
14
|
+
|
|
15
|
+
[plugins]
|
|
16
|
+
ms365 = "0.1.0"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import shade_plugins_ms365 as ms365
|
|
21
|
+
|
|
22
|
+
for email in ms365.ler_emails("inbox", nao_lidos=True):
|
|
23
|
+
ms365.baixar_anexos(item, email["id"])
|
|
24
|
+
|
|
25
|
+
with item.efetivar("avisar o cliente"):
|
|
26
|
+
ms365.enviar_email("cliente@exemplo.com", f"Pedido {item['pedido']}", "Recebemos o pedido.")
|
|
27
|
+
|
|
28
|
+
site = "contoso.sharepoint.com:/sites/Financeiro"
|
|
29
|
+
for arquivo in ms365.listar_arquivos(site, "Notas/2026"):
|
|
30
|
+
ms365.baixar_arquivo(site, "Notas/2026/" + arquivo["nome"])
|
|
31
|
+
|
|
32
|
+
with item.efetivar("guardar o comprovante"):
|
|
33
|
+
ms365.enviar_arquivo("comprovante.pdf", site, "Comprovantes")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- **Conta:** a conexão ``microsoft-365`` da plataforma (Conectores). O robô nunca vê a senha nem
|
|
37
|
+
o refresh token: o token de acesso chega sob demanda e é renovado pela plataforma. No laptop,
|
|
38
|
+
ponha um token de acesso em ``SHADE_TOKEN_MICROSOFT_365``. A caixa de e-mail é a da conta
|
|
39
|
+
conectada (``/me``); caixa compartilhada fica fora desta versão.
|
|
40
|
+
- **Ler não muda nada:** ``ler_emails``, ``ler_email``, ``baixar_anexos``, ``listar_arquivos`` e
|
|
41
|
+
``baixar_arquivo`` rodam inclusive no ensaio, e nenhuma mensagem vira lida.
|
|
42
|
+
- **Escrever só dentro de** ``item.efetivar()`` (no ensaio não roda):
|
|
43
|
+
- ``enviar_email`` não duplica: a mensagem leva um ``Message-ID`` derivado da chave do item,
|
|
44
|
+
do nome da efetivação e da ordem do envio, e antes de criar o rascunho o plugin procura esse
|
|
45
|
+
identificador em Enviados e em Rascunhos. Depois que o envio saiu sem confirmação, o plugin
|
|
46
|
+
não envia de novo: ele confere Enviados e, sem achar, o erro é ``GraphIncerto`` (o item fica
|
|
47
|
+
retido para conferência).
|
|
48
|
+
- ``enviar_arquivo`` grava no caminho dado: repetir sobrescreve o mesmo arquivo, então a
|
|
49
|
+
retentativa e o "refazer" deixam um arquivo só. ``existente="falhar"`` recusa o caminho
|
|
50
|
+
ocupado, e ``chave_no_nome=True`` põe a chave da chamada no nome, para um item não sobrescrever
|
|
51
|
+
o arquivo de outro.
|
|
52
|
+
- **Prazo:** ``timeout=`` (30 s) vale para a chamada inteira, retentativas incluídas.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import hashlib
|
|
58
|
+
import json
|
|
59
|
+
import mimetypes
|
|
60
|
+
import os
|
|
61
|
+
import re
|
|
62
|
+
import urllib.parse
|
|
63
|
+
from collections.abc import Callable
|
|
64
|
+
from datetime import UTC, datetime
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
from typing import Any
|
|
67
|
+
|
|
68
|
+
from shade_sdk.plugins import Plugin
|
|
69
|
+
|
|
70
|
+
from shade_plugins_ms365 import _cliente
|
|
71
|
+
from shade_plugins_ms365._cliente import CONEXAO, GRAPH, RAIZ, Cliente, Prazo
|
|
72
|
+
from shade_plugins_ms365.erros import (
|
|
73
|
+
ArquivoJaExiste,
|
|
74
|
+
ErroDaGraph,
|
|
75
|
+
GraphIncerto,
|
|
76
|
+
GraphNaoSaiu,
|
|
77
|
+
NaoEncontrado,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
__version__ = "0.1.0"
|
|
81
|
+
|
|
82
|
+
#: A chave de ``[config]`` com o host do tenant no SharePoint, para onde a Graph redireciona o
|
|
83
|
+
#: download (``contoso.sharepoint.com``).
|
|
84
|
+
HOST_DO_SHAREPOINT = "sharepoint_host"
|
|
85
|
+
#: Quantos itens as listagens devolvem, no máximo, e quantos a Graph dá por página.
|
|
86
|
+
LIMITE_EMAILS_MAX = 1_000
|
|
87
|
+
LIMITE_ARQUIVOS_MAX = 10_000
|
|
88
|
+
POR_PAGINA = 100
|
|
89
|
+
#: Quantas páginas ``ler_emails`` percorre atrás do que passa nos filtros do cliente.
|
|
90
|
+
PAGINAS_MAX = 50
|
|
91
|
+
#: O teto de um download (anexo ou arquivo) e de um envio (um ``PUT`` só).
|
|
92
|
+
ARQUIVO_MAX = 150 * 1024 * 1024
|
|
93
|
+
ENVIO_MAX = 100 * 1024 * 1024
|
|
94
|
+
#: O teto do texto de um e-mail e dos destinatários.
|
|
95
|
+
CORPO_MAX = 1_000_000
|
|
96
|
+
DESTINATARIOS_MAX = 100
|
|
97
|
+
#: O domínio do ``Message-ID``: o ``.invalid`` é reservado (RFC 2606) e nunca é de ninguém.
|
|
98
|
+
DOMINIO_DO_ID = "ms365.shadeone.invalid"
|
|
99
|
+
#: Quantos caracteres da ``chamada.idempotencia`` vão no ``Message-ID`` e no nome com chave.
|
|
100
|
+
TAMANHO_DA_CHAVE = 32
|
|
101
|
+
TAMANHO_DA_CHAVE_NO_NOME = 12
|
|
102
|
+
_CONTROLE = re.compile(r"[\x00-\x1f\x7f]")
|
|
103
|
+
#: O id de mensagem, pasta, anexo e item da Graph, e o nome conhecido da pasta (``inbox``).
|
|
104
|
+
_ID = re.compile(r"^[A-Za-z0-9_=+\-]{1,512}$")
|
|
105
|
+
_ENDERECO = re.compile(
|
|
106
|
+
r"^[^\s<>()\[\]\\,;:@\"]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9.\-]{0,251}[A-Za-z0-9])?$"
|
|
107
|
+
)
|
|
108
|
+
_HOST = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9.\-]{0,251}[A-Za-z0-9])?$")
|
|
109
|
+
_GUID = r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}"
|
|
110
|
+
_SITE_POR_ID = re.compile(rf"^({_HOST.pattern[1:-1]}),({_GUID}),({_GUID})$")
|
|
111
|
+
_SITE_POR_CAMINHO = re.compile(rf"^({_HOST.pattern[1:-1]}):((?:/[^/]+)+)$")
|
|
112
|
+
#: O que o SharePoint não aceita num nome (e o ``/`` que separa os segmentos).
|
|
113
|
+
_PROIBIDOS_NO_NOME = set('"*:<>?/\\|')
|
|
114
|
+
_ANEXO_DE_ARQUIVO = "#microsoft.graph.fileAttachment"
|
|
115
|
+
#: O conteúdo de um anexo ou de um arquivo é binário: o ``Accept`` padrão (JSON) não serve.
|
|
116
|
+
_QUALQUER_TIPO = {"Accept": "*/*"}
|
|
117
|
+
|
|
118
|
+
PLUGIN = Plugin(
|
|
119
|
+
"ms365",
|
|
120
|
+
__version__,
|
|
121
|
+
shade_sdk=">=0.22.3,<0.23",
|
|
122
|
+
dono="Equipe de integrações da ShadeOne",
|
|
123
|
+
hosts=[GRAPH],
|
|
124
|
+
hosts_do_projeto=[HOST_DO_SHAREPOINT],
|
|
125
|
+
conexoes=[CONEXAO],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
__all__ = [
|
|
129
|
+
"PLUGIN",
|
|
130
|
+
"ArquivoJaExiste",
|
|
131
|
+
"ErroDaGraph",
|
|
132
|
+
"GraphIncerto",
|
|
133
|
+
"GraphNaoSaiu",
|
|
134
|
+
"NaoEncontrado",
|
|
135
|
+
"baixar_anexos",
|
|
136
|
+
"baixar_arquivo",
|
|
137
|
+
"enviar_arquivo",
|
|
138
|
+
"enviar_email",
|
|
139
|
+
"ler_email",
|
|
140
|
+
"ler_emails",
|
|
141
|
+
"listar_arquivos",
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── as réguas ─────────────────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _id(valor: Any, campo: str) -> str:
|
|
149
|
+
if not isinstance(valor, str) or not _ID.fullmatch(valor):
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"{campo} é o id que a Graph devolveu (letras, dígitos, '-', '_', '=', '+')"
|
|
152
|
+
)
|
|
153
|
+
return urllib.parse.quote(valor, safe="")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _porta(porta: Any) -> int | None:
|
|
157
|
+
if porta is not None and (
|
|
158
|
+
not isinstance(porta, int) or isinstance(porta, bool) or not 0 < porta < 65536
|
|
159
|
+
):
|
|
160
|
+
raise ValueError(f"porta é um número de 1 a 65535, não {porta!r}")
|
|
161
|
+
return porta
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _limite(valor: Any, maximo: int) -> int:
|
|
165
|
+
if not isinstance(valor, int) or isinstance(valor, bool) or not 0 < valor <= maximo:
|
|
166
|
+
raise ValueError(f"limite é um número de 1 a {maximo}")
|
|
167
|
+
return valor
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def literal_odata(valor: str) -> str:
|
|
171
|
+
"""O texto como literal de um ``$filter`` do OData: entre aspas simples, com a aspa do valor
|
|
172
|
+
dobrada (a regra do OData). Quebra de linha e controle são recusados."""
|
|
173
|
+
if not isinstance(valor, str) or _CONTROLE.search(valor):
|
|
174
|
+
raise ValueError("o texto do filtro não pode ter quebra de linha nem caractere de controle")
|
|
175
|
+
return "'" + valor.replace("'", "''") + "'"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _segmento(valor: Any, campo: str) -> str:
|
|
179
|
+
"""Um nome de pasta ou arquivo do SharePoint, já codificado para o caminho."""
|
|
180
|
+
if (
|
|
181
|
+
not isinstance(valor, str)
|
|
182
|
+
or not valor
|
|
183
|
+
or len(valor) > 255
|
|
184
|
+
or valor in (".", "..")
|
|
185
|
+
or valor != valor.strip()
|
|
186
|
+
or valor.endswith(".")
|
|
187
|
+
or _CONTROLE.search(valor)
|
|
188
|
+
or any(c in _PROIBIDOS_NO_NOME for c in valor)
|
|
189
|
+
):
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"{campo}: nome inválido no SharePoint (vazio, '.', '..', espaço nas pontas, ponto "
|
|
192
|
+
'no fim, controle ou um destes: " * : < > ? / \\ |)'
|
|
193
|
+
)
|
|
194
|
+
return urllib.parse.quote(valor, safe="")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _caminho(valor: Any, campo: str, *, vazio: bool) -> list[str]:
|
|
198
|
+
"""Os segmentos já codificados de um caminho relativo (``Notas/2026``)."""
|
|
199
|
+
if not isinstance(valor, str):
|
|
200
|
+
raise ValueError(f"{campo} é um caminho relativo, como 'Notas/2026'")
|
|
201
|
+
texto = valor.strip("/")
|
|
202
|
+
if not texto:
|
|
203
|
+
if vazio:
|
|
204
|
+
return []
|
|
205
|
+
raise ValueError(f"{campo} é um caminho relativo, como 'Notas/2026/nota.pdf'")
|
|
206
|
+
return [_segmento(parte, campo) for parte in texto.split("/")]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _site(valor: Any) -> str:
|
|
210
|
+
"""O site como a Graph o endereça, em ``/sites/...``: ``root``, o id
|
|
211
|
+
(``contoso.sharepoint.com,<guid>,<guid>``) ou ``host:/sites/Nome``. No caminho do site, a
|
|
212
|
+
forma por caminho leva o ``:`` que fecha o segmento."""
|
|
213
|
+
if valor == "root":
|
|
214
|
+
return "/sites/root"
|
|
215
|
+
if isinstance(valor, str) and _SITE_POR_ID.fullmatch(valor):
|
|
216
|
+
return "/sites/" + urllib.parse.quote(valor, safe=",")
|
|
217
|
+
if isinstance(valor, str) and (achado := _SITE_POR_CAMINHO.fullmatch(valor)):
|
|
218
|
+
partes = [_segmento(p, "site") for p in achado[2].strip("/").split("/")]
|
|
219
|
+
return f"/sites/{achado[1]}:/{'/'.join(partes)}:"
|
|
220
|
+
raise ValueError(
|
|
221
|
+
"site é 'root', o id do site (host,guid,guid) ou 'host:/sites/Nome', "
|
|
222
|
+
"como 'contoso.sharepoint.com:/sites/Financeiro'"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _drive(site: str) -> str:
|
|
227
|
+
"""O drive padrão do site (a biblioteca de documentos)."""
|
|
228
|
+
return f"{RAIZ}{site}/drive"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _no_drive(site: str, partes: list[str], *, depois: str = "") -> str:
|
|
232
|
+
"""O caminho de um item do drive do site: a raiz (sem ``partes``) ou ``root:/a/b:``."""
|
|
233
|
+
if not partes:
|
|
234
|
+
return f"{_drive(site)}/root{depois}"
|
|
235
|
+
return f"{_drive(site)}/root:/{'/'.join(partes)}:{depois}"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _nome_de_arquivo(nome: str, usados: set[str] | None = None) -> str:
|
|
239
|
+
limpo = _CONTROLE.sub("_", nome.replace("/", "_").replace("\\", "_")).strip(". ")
|
|
240
|
+
limpo = (limpo or "arquivo")[:200]
|
|
241
|
+
if usados is None:
|
|
242
|
+
return limpo
|
|
243
|
+
final, contador = limpo, 1
|
|
244
|
+
while final.casefold() in usados:
|
|
245
|
+
contador += 1
|
|
246
|
+
raiz, extensao = os.path.splitext(limpo)
|
|
247
|
+
final = f"{raiz} ({contador}){extensao}"
|
|
248
|
+
usados.add(final.casefold())
|
|
249
|
+
return final
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _pasta_relativa(pasta: Any) -> Path:
|
|
253
|
+
if not isinstance(pasta, str) or not pasta or _CONTROLE.search(pasta):
|
|
254
|
+
raise ValueError("pasta é um caminho relativo do diretório de trabalho")
|
|
255
|
+
partes = pasta.split("/")
|
|
256
|
+
if pasta.startswith("/") or any(p in ("", ".", "..") for p in partes):
|
|
257
|
+
raise ValueError("pasta é um caminho relativo, sem '..' nem segmento vazio")
|
|
258
|
+
return Path(pasta)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _arquivo_do_workdir(caminho: Any) -> Path:
|
|
262
|
+
if not isinstance(caminho, str | os.PathLike):
|
|
263
|
+
raise TypeError("arquivo é o caminho de um arquivo do diretório de trabalho")
|
|
264
|
+
relativo = Path(caminho)
|
|
265
|
+
raiz = Path.cwd().resolve()
|
|
266
|
+
completo = (raiz / relativo).resolve()
|
|
267
|
+
if relativo.is_absolute() or completo != raiz and raiz not in completo.parents:
|
|
268
|
+
raise ValueError("arquivo é um caminho dentro do diretório de trabalho")
|
|
269
|
+
if not completo.is_file():
|
|
270
|
+
raise FileNotFoundError("arquivo: não existe no diretório de trabalho")
|
|
271
|
+
return completo
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _enderecos(valor: Any, campo: str, *, obrigatorio: bool) -> list[str]:
|
|
275
|
+
if valor is None:
|
|
276
|
+
valor = []
|
|
277
|
+
if isinstance(valor, str):
|
|
278
|
+
valor = [valor]
|
|
279
|
+
if not isinstance(valor, list | tuple):
|
|
280
|
+
raise TypeError(f"{campo} é um endereço ou uma lista de endereços")
|
|
281
|
+
if obrigatorio and not valor:
|
|
282
|
+
raise ValueError(f"{campo} precisa de ao menos um destinatário")
|
|
283
|
+
resultado = []
|
|
284
|
+
for endereco in valor:
|
|
285
|
+
if not isinstance(endereco, str) or not _ENDERECO.fullmatch(endereco.strip()):
|
|
286
|
+
raise ValueError(f"{campo}: endereço de e-mail inválido")
|
|
287
|
+
resultado.append(endereco.strip())
|
|
288
|
+
return resultado
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _destinatarios(enderecos: list[str]) -> list[dict[str, Any]]:
|
|
292
|
+
return [{"emailAddress": {"address": e}} for e in enderecos]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _json(dados: Any) -> bytes:
|
|
296
|
+
return json.dumps(dados, ensure_ascii=False).encode("utf-8")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _lendo(operacao: str, funcao: Callable[[], Any]) -> Any:
|
|
300
|
+
"""A falha de rede no meio da leitura vira ``ErroDaGraph``."""
|
|
301
|
+
try:
|
|
302
|
+
return funcao()
|
|
303
|
+
except ErroDaGraph:
|
|
304
|
+
raise
|
|
305
|
+
except OSError as exc:
|
|
306
|
+
raise _cliente.falha_de_leitura(operacao, exc) from exc
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _cliente_da_chamada(chamada: Any, porta: Any) -> Cliente:
|
|
310
|
+
return Cliente(chamada=chamada, prazo=Prazo(chamada.timeout), porta=_porta(porta))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _paginas(cliente: Cliente, operacao: str, caminho: str, parametros: dict[str, str]) -> Any:
|
|
314
|
+
"""Os itens de ``value`` de cada página, seguindo ``@odata.nextLink`` só dentro do host da
|
|
315
|
+
Graph (o link é dado do servidor: o token não vai a outro host)."""
|
|
316
|
+
consulta_crua = ""
|
|
317
|
+
while True:
|
|
318
|
+
resposta = cliente.pedir(
|
|
319
|
+
"GET", GRAPH, caminho, parametros=parametros, consulta_crua=consulta_crua
|
|
320
|
+
)
|
|
321
|
+
_cliente.checar_leitura(operacao, resposta)
|
|
322
|
+
dados = resposta.json()
|
|
323
|
+
for item in dados.get("value") or []:
|
|
324
|
+
if isinstance(item, dict):
|
|
325
|
+
yield item
|
|
326
|
+
proximo = dados.get("@odata.nextLink")
|
|
327
|
+
if not isinstance(proximo, str) or not proximo:
|
|
328
|
+
return
|
|
329
|
+
partes = urllib.parse.urlsplit(proximo)
|
|
330
|
+
if (
|
|
331
|
+
partes.scheme != "https"
|
|
332
|
+
or (partes.hostname or "").lower() != GRAPH
|
|
333
|
+
or partes.username
|
|
334
|
+
or not partes.path.startswith(RAIZ + "/")
|
|
335
|
+
):
|
|
336
|
+
raise ErroDaGraph(f"Graph {operacao}: a próxima página aponta para fora da Graph")
|
|
337
|
+
caminho, parametros, consulta_crua = partes.path, {}, partes.query
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ── Outlook: ler ──────────────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
_CAMPOS_DA_MENSAGEM = "id,internetMessageId,subject,from,receivedDateTime,isRead,hasAttachments"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _endereco_do_remetente(mensagem: dict[str, Any]) -> tuple[str, str]:
|
|
346
|
+
de = (mensagem.get("from") or {}).get("emailAddress") or {}
|
|
347
|
+
return str(de.get("address") or ""), str(de.get("name") or "")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _resumo(mensagem: dict[str, Any]) -> dict[str, Any]:
|
|
351
|
+
endereco, nome = _endereco_do_remetente(mensagem)
|
|
352
|
+
return {
|
|
353
|
+
"id": mensagem.get("id", ""),
|
|
354
|
+
"internet_id": mensagem.get("internetMessageId", ""),
|
|
355
|
+
"assunto": mensagem.get("subject") or "",
|
|
356
|
+
"de": endereco,
|
|
357
|
+
"de_nome": nome,
|
|
358
|
+
"recebido": mensagem.get("receivedDateTime", ""),
|
|
359
|
+
"lido": bool(mensagem.get("isRead")),
|
|
360
|
+
"tem_anexos": bool(mensagem.get("hasAttachments")),
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _instante(valor: Any) -> str:
|
|
365
|
+
"""``desde`` como o instante UTC que o OData lê (``2026-10-25T00:00:00Z``)."""
|
|
366
|
+
if isinstance(valor, datetime):
|
|
367
|
+
instante = valor
|
|
368
|
+
elif isinstance(valor, str):
|
|
369
|
+
try:
|
|
370
|
+
instante = datetime.fromisoformat(valor.strip().replace("Z", "+00:00"))
|
|
371
|
+
except ValueError:
|
|
372
|
+
raise ValueError(
|
|
373
|
+
"desde é uma data ISO, como '2026-10-25' ou '2026-10-25T08:00:00Z'"
|
|
374
|
+
) from None
|
|
375
|
+
else:
|
|
376
|
+
raise ValueError("desde é uma data ISO, como '2026-10-25' ou '2026-10-25T08:00:00Z'")
|
|
377
|
+
if instante.tzinfo is None:
|
|
378
|
+
instante = instante.replace(tzinfo=UTC)
|
|
379
|
+
return instante.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@PLUGIN.operacao
|
|
383
|
+
def ler_emails(
|
|
384
|
+
chamada: Any,
|
|
385
|
+
pasta: str = "inbox",
|
|
386
|
+
*,
|
|
387
|
+
nao_lidos: bool = False,
|
|
388
|
+
desde: str | datetime | None = None,
|
|
389
|
+
remetente: str | None = None,
|
|
390
|
+
assunto: str | None = None,
|
|
391
|
+
limite: int = 50,
|
|
392
|
+
porta: int | None = None,
|
|
393
|
+
) -> list[dict[str, Any]]:
|
|
394
|
+
"""As mensagens de ``pasta`` (``inbox``, ``sentitems``, ``archive``... ou o id de uma pasta),
|
|
395
|
+
as mais recentes primeiro, até ``limite``. ``nao_lidos`` e ``desde`` (uma data ISO) filtram na
|
|
396
|
+
Graph; ``remetente`` e ``assunto`` (um pedaço do endereço e do assunto, sem diferenciar
|
|
397
|
+
maiúsculas) filtram aqui, depois da leitura. Cada uma: ``id``, ``internet_id``, ``assunto``,
|
|
398
|
+
``de``, ``de_nome``, ``recebido``, ``lido`` e ``tem_anexos``. Nada vira lido."""
|
|
399
|
+
destino = _id(pasta, "pasta")
|
|
400
|
+
limite = _limite(limite, LIMITE_EMAILS_MAX)
|
|
401
|
+
parametros = {"$select": _CAMPOS_DA_MENSAGEM, "$top": str(POR_PAGINA)}
|
|
402
|
+
filtros = []
|
|
403
|
+
if nao_lidos:
|
|
404
|
+
filtros.append("isRead eq false")
|
|
405
|
+
if desde is not None:
|
|
406
|
+
filtros.append(f"receivedDateTime ge {_instante(desde)}")
|
|
407
|
+
if filtros:
|
|
408
|
+
parametros["$filter"] = " and ".join(filtros)
|
|
409
|
+
for campo, valor in (("remetente", remetente), ("assunto", assunto)):
|
|
410
|
+
if valor is not None and (not isinstance(valor, str) or not valor):
|
|
411
|
+
raise ValueError(f"{campo} é um texto não vazio")
|
|
412
|
+
cliente = _cliente_da_chamada(chamada, porta)
|
|
413
|
+
caminho = f"{RAIZ}/me/mailFolders/{destino}/messages"
|
|
414
|
+
|
|
415
|
+
def ler() -> list[dict[str, Any]]:
|
|
416
|
+
achadas: list[dict[str, Any]] = []
|
|
417
|
+
for percorridas, mensagem in enumerate(
|
|
418
|
+
_paginas(cliente, "ler_emails", caminho, parametros)
|
|
419
|
+
):
|
|
420
|
+
if percorridas >= PAGINAS_MAX * POR_PAGINA:
|
|
421
|
+
break
|
|
422
|
+
resumo = _resumo(mensagem)
|
|
423
|
+
if remetente and remetente.casefold() not in resumo["de"].casefold():
|
|
424
|
+
continue
|
|
425
|
+
if assunto and assunto.casefold() not in resumo["assunto"].casefold():
|
|
426
|
+
continue
|
|
427
|
+
achadas.append(resumo)
|
|
428
|
+
if len(achadas) >= limite:
|
|
429
|
+
break
|
|
430
|
+
return achadas
|
|
431
|
+
|
|
432
|
+
return _lendo("ler_emails", ler)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@PLUGIN.operacao
|
|
436
|
+
def ler_email(chamada: Any, mensagem: str, *, porta: int | None = None) -> dict[str, Any]:
|
|
437
|
+
"""Uma mensagem inteira, pelo ``id`` de ``ler_emails``: o resumo mais ``para`` e ``cc`` (listas
|
|
438
|
+
de endereços) e ``corpo`` (em texto). Não marca como lida."""
|
|
439
|
+
identificador = _id(mensagem, "mensagem")
|
|
440
|
+
cliente = _cliente_da_chamada(chamada, porta)
|
|
441
|
+
|
|
442
|
+
def ler() -> dict[str, Any]:
|
|
443
|
+
resposta = cliente.pedir(
|
|
444
|
+
"GET",
|
|
445
|
+
GRAPH,
|
|
446
|
+
f"{RAIZ}/me/messages/{identificador}",
|
|
447
|
+
parametros={"$select": f"{_CAMPOS_DA_MENSAGEM},body,toRecipients,ccRecipients"},
|
|
448
|
+
cabecalhos={"Prefer": 'outlook.body-content-type="text"'},
|
|
449
|
+
)
|
|
450
|
+
_cliente.checar_leitura("ler_email", resposta)
|
|
451
|
+
dados = resposta.json()
|
|
452
|
+
|
|
453
|
+
def enderecos(campo: str) -> list[str]:
|
|
454
|
+
return [
|
|
455
|
+
str((r.get("emailAddress") or {}).get("address") or "")
|
|
456
|
+
for r in dados.get(campo) or []
|
|
457
|
+
if isinstance(r, dict)
|
|
458
|
+
]
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
**_resumo(dados),
|
|
462
|
+
"para": enderecos("toRecipients"),
|
|
463
|
+
"cc": enderecos("ccRecipients"),
|
|
464
|
+
"corpo": str((dados.get("body") or {}).get("content") or ""),
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return _lendo("ler_email", ler)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@PLUGIN.operacao
|
|
471
|
+
def baixar_anexos(
|
|
472
|
+
chamada: Any,
|
|
473
|
+
item: Any,
|
|
474
|
+
mensagem: str,
|
|
475
|
+
*,
|
|
476
|
+
nomes: list[str] | tuple[str, ...] | str | None = None,
|
|
477
|
+
incluir_inline: bool = False,
|
|
478
|
+
pasta: str = "anexos",
|
|
479
|
+
porta: int | None = None,
|
|
480
|
+
) -> list[dict[str, Any]]:
|
|
481
|
+
"""Os anexos de arquivo da mensagem ``mensagem`` (o ``id`` de ``ler_emails``) gravados em
|
|
482
|
+
``<pasta>/<12 caracteres do id>/`` no diretório de trabalho e anexados ao item por
|
|
483
|
+
``item.anexar`` (a saída do run). ``nomes`` escolhe quais; as imagens embutidas no corpo só
|
|
484
|
+
entram com ``incluir_inline``. Cada um: ``nome`` (o da mensagem), ``arquivo`` (o caminho
|
|
485
|
+
gravado, com o nome limpo de separador e controle), ``tipo``, ``tamanho`` e ``anexado``
|
|
486
|
+
(``False`` acima do teto da saída do run; o arquivo fica no disco). O anexo que é outra
|
|
487
|
+
mensagem ou um link vem com ``ignorado`` e sem arquivo. A mensagem não é marcada como lida."""
|
|
488
|
+
anexar = getattr(item, "anexar", None)
|
|
489
|
+
if not callable(anexar):
|
|
490
|
+
raise TypeError("baixar_anexos(item, mensagem): passe o item do processar()")
|
|
491
|
+
identificador = _id(mensagem, "mensagem")
|
|
492
|
+
escolhidos = None if nomes is None else ({nomes} if isinstance(nomes, str) else set(nomes))
|
|
493
|
+
destino = _pasta_relativa(pasta) / hashlib.sha256(mensagem.encode()).hexdigest()[:12]
|
|
494
|
+
cliente = _cliente_da_chamada(chamada, porta)
|
|
495
|
+
base = f"{RAIZ}/me/messages/{identificador}/attachments"
|
|
496
|
+
raiz = Path.cwd().resolve()
|
|
497
|
+
|
|
498
|
+
def ler() -> list[dict[str, Any]]:
|
|
499
|
+
resultado: list[dict[str, Any]] = []
|
|
500
|
+
usados: set[str] = set()
|
|
501
|
+
parametros = {"$select": "id,name,contentType,size,isInline", "$top": str(POR_PAGINA)}
|
|
502
|
+
for anexo in _paginas(cliente, "baixar_anexos", base, parametros):
|
|
503
|
+
nome = str(anexo.get("name") or "")
|
|
504
|
+
tipo = str(anexo.get("contentType") or "")
|
|
505
|
+
if escolhidos is not None and nome not in escolhidos:
|
|
506
|
+
continue
|
|
507
|
+
if anexo.get("@odata.type") != _ANEXO_DE_ARQUIVO:
|
|
508
|
+
resultado.append(
|
|
509
|
+
{
|
|
510
|
+
"nome": nome,
|
|
511
|
+
"arquivo": None,
|
|
512
|
+
"tipo": tipo,
|
|
513
|
+
"tamanho": None,
|
|
514
|
+
"anexado": False,
|
|
515
|
+
"ignorado": "anexo que não é arquivo (mensagem ou link)",
|
|
516
|
+
}
|
|
517
|
+
)
|
|
518
|
+
continue
|
|
519
|
+
if anexo.get("isInline") and not incluir_inline:
|
|
520
|
+
continue
|
|
521
|
+
identificador_do_anexo = anexo.get("id")
|
|
522
|
+
if not isinstance(identificador_do_anexo, str) or not _ID.fullmatch(
|
|
523
|
+
identificador_do_anexo
|
|
524
|
+
):
|
|
525
|
+
raise ErroDaGraph("Graph baixar_anexos: anexo sem um id que a Graph reconheça")
|
|
526
|
+
pasta_completa = raiz / destino
|
|
527
|
+
pasta_completa.mkdir(parents=True, exist_ok=True)
|
|
528
|
+
gravado = pasta_completa / _nome_de_arquivo(nome, usados)
|
|
529
|
+
parcial = pasta_completa / (gravado.name + ".parcial")
|
|
530
|
+
try:
|
|
531
|
+
conteudo = cliente.pedir(
|
|
532
|
+
"GET",
|
|
533
|
+
GRAPH,
|
|
534
|
+
f"{base}/{urllib.parse.quote(identificador_do_anexo, safe='')}/$value",
|
|
535
|
+
cabecalhos=_QUALQUER_TIPO,
|
|
536
|
+
para_arquivo=parcial,
|
|
537
|
+
tamanho_max=ARQUIVO_MAX,
|
|
538
|
+
)
|
|
539
|
+
_cliente.checar_leitura("baixar_anexos", conteudo)
|
|
540
|
+
parcial.replace(gravado)
|
|
541
|
+
finally:
|
|
542
|
+
parcial.unlink(missing_ok=True)
|
|
543
|
+
resultado.append(
|
|
544
|
+
{
|
|
545
|
+
"nome": nome,
|
|
546
|
+
"arquivo": str(gravado.relative_to(raiz)),
|
|
547
|
+
"tipo": tipo,
|
|
548
|
+
"tamanho": conteudo.tamanho,
|
|
549
|
+
"anexado": bool(anexar(gravado)),
|
|
550
|
+
}
|
|
551
|
+
)
|
|
552
|
+
return resultado
|
|
553
|
+
|
|
554
|
+
return _lendo("baixar_anexos", ler)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
# ── Outlook: enviar ───────────────────────────────────────────────────────────────────────
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class _AindaNaoSaiu(Exception):
|
|
561
|
+
"""Uma volta do envio falhou antes de despachar o ``send`` (o rascunho não ficou criado, ou
|
|
562
|
+
a resposta dele se perdeu): a volta seguinte procura o rascunho antes de criar outro."""
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _procurar(cliente: Cliente, pasta: str, mid: str, marca: str) -> dict[str, Any] | None:
|
|
566
|
+
"""A mensagem de ``pasta`` (``sentitems`` ou ``drafts``) com este ``internetMessageId``."""
|
|
567
|
+
try:
|
|
568
|
+
resposta = cliente.pedir(
|
|
569
|
+
"GET",
|
|
570
|
+
GRAPH,
|
|
571
|
+
f"{RAIZ}/me/mailFolders/{pasta}/messages",
|
|
572
|
+
parametros={
|
|
573
|
+
"$filter": f"internetMessageId eq {literal_odata(mid)}",
|
|
574
|
+
"$select": "id,internetMessageId",
|
|
575
|
+
"$top": "1",
|
|
576
|
+
},
|
|
577
|
+
idempotencia=marca,
|
|
578
|
+
)
|
|
579
|
+
_cliente.checar_leitura("enviar_email", resposta)
|
|
580
|
+
achadas = resposta.json().get("value") or []
|
|
581
|
+
except (ErroDaGraph, OSError) as exc:
|
|
582
|
+
# Sem a busca, não se envia (o token que não veio já é um ``http.NadaSaiu``).
|
|
583
|
+
raise GraphNaoSaiu(f"Graph enviar_email: a busca do identificador falhou: {exc}") from exc
|
|
584
|
+
return achadas[0] if achadas and isinstance(achadas[0], dict) else None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _criar_rascunho(cliente: Cliente, rascunho: bytes, marca: str) -> str:
|
|
588
|
+
try:
|
|
589
|
+
resposta = cliente.pedir(
|
|
590
|
+
"POST",
|
|
591
|
+
GRAPH,
|
|
592
|
+
f"{RAIZ}/me/messages",
|
|
593
|
+
corpo=rascunho,
|
|
594
|
+
tipo="application/json",
|
|
595
|
+
idempotencia=marca,
|
|
596
|
+
repetir_depois_do_envio=False,
|
|
597
|
+
)
|
|
598
|
+
except OSError as exc:
|
|
599
|
+
raise _AindaNaoSaiu() from exc
|
|
600
|
+
if resposta.status >= 500:
|
|
601
|
+
raise _AindaNaoSaiu()
|
|
602
|
+
_cliente.checar_escrita("enviar_email", resposta)
|
|
603
|
+
try:
|
|
604
|
+
criado = resposta.json().get("id")
|
|
605
|
+
except ErroDaGraph:
|
|
606
|
+
criado = None
|
|
607
|
+
if not isinstance(criado, str) or not _ID.fullmatch(criado):
|
|
608
|
+
# Criou, mas não dá para enviar o que não se identifica: a volta seguinte o acha.
|
|
609
|
+
raise _AindaNaoSaiu()
|
|
610
|
+
return criado
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _despachar_o_envio(cliente: Cliente, mid: str, marca: str, id_do_rascunho: str) -> None:
|
|
614
|
+
"""O ``send`` do rascunho. Depois de despachado, nenhuma volta repete: a dúvida confere
|
|
615
|
+
Enviados e, sem achar, é ``GraphIncerto``."""
|
|
616
|
+
try:
|
|
617
|
+
resposta = cliente.pedir(
|
|
618
|
+
"POST",
|
|
619
|
+
GRAPH,
|
|
620
|
+
f"{RAIZ}/me/messages/{urllib.parse.quote(id_do_rascunho, safe='')}/send",
|
|
621
|
+
idempotencia=marca,
|
|
622
|
+
repetir_depois_do_envio=False,
|
|
623
|
+
)
|
|
624
|
+
_cliente.checar_escrita("enviar_email", resposta)
|
|
625
|
+
return
|
|
626
|
+
except GraphIncerto as incerto:
|
|
627
|
+
duvida: Exception = incerto
|
|
628
|
+
except OSError as exc:
|
|
629
|
+
duvida = _cliente.falha_de_escrita("enviar_email", exc)
|
|
630
|
+
if isinstance(duvida, GraphNaoSaiu):
|
|
631
|
+
raise duvida from exc
|
|
632
|
+
# O envio pode ter saído: o que o confirma é a mensagem em Enviados.
|
|
633
|
+
try:
|
|
634
|
+
confirmada = _procurar(cliente, "sentitems", mid, marca)
|
|
635
|
+
except GraphNaoSaiu:
|
|
636
|
+
confirmada = None
|
|
637
|
+
if confirmada is None:
|
|
638
|
+
raise duvida
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@PLUGIN.efeito
|
|
643
|
+
def enviar_email(
|
|
644
|
+
chamada: Any,
|
|
645
|
+
para: str | list[str],
|
|
646
|
+
assunto: str,
|
|
647
|
+
corpo: str,
|
|
648
|
+
*,
|
|
649
|
+
cc: str | list[str] | None = None,
|
|
650
|
+
cco: str | list[str] | None = None,
|
|
651
|
+
html: bool = False,
|
|
652
|
+
porta: int | None = None,
|
|
653
|
+
) -> dict[str, Any]:
|
|
654
|
+
"""Envia um e-mail da conta conectada, só dentro de ``item.efetivar()``. ``corpo`` é texto (ou
|
|
655
|
+
HTML, com ``html=True``: o HTML é do robô). Devolve ``internet_id`` (o ``Message-ID``) e
|
|
656
|
+
``reaproveitado`` (``True`` quando a mensagem desta mesma chamada já estava em Enviados).
|
|
657
|
+
|
|
658
|
+
Não duplica: a mensagem leva o ``Message-ID`` derivado da chave do item, do nome da
|
|
659
|
+
efetivação e da ordem do envio, e antes de criar o rascunho o plugin procura esse
|
|
660
|
+
identificador em Enviados e em Rascunhos. ``GraphNaoSaiu``: nada foi enviado (a Graph
|
|
661
|
+
recusou, a rede não abriu, a busca não respondeu). ``GraphIncerto``: o envio saiu sem
|
|
662
|
+
confirmação e a mensagem não está em Enviados; o plugin não envia de novo."""
|
|
663
|
+
destinatarios = _enderecos(para, "para", obrigatorio=True)
|
|
664
|
+
copia = _enderecos(cc, "cc", obrigatorio=False)
|
|
665
|
+
oculta = _enderecos(cco, "cco", obrigatorio=False)
|
|
666
|
+
if len(destinatarios) + len(copia) + len(oculta) > DESTINATARIOS_MAX:
|
|
667
|
+
raise ValueError(f"no máximo {DESTINATARIOS_MAX} destinatários")
|
|
668
|
+
if not isinstance(assunto, str) or not assunto.strip() or len(assunto) > 255:
|
|
669
|
+
raise ValueError("assunto é um texto de 1 a 255 caracteres")
|
|
670
|
+
if _CONTROLE.search(assunto):
|
|
671
|
+
raise ValueError("assunto não pode ter quebra de linha nem caractere de controle")
|
|
672
|
+
if not isinstance(corpo, str) or len(corpo) > CORPO_MAX:
|
|
673
|
+
raise ValueError(f"corpo é um texto de até {CORPO_MAX} caracteres")
|
|
674
|
+
if not isinstance(html, bool):
|
|
675
|
+
raise ValueError("html é verdadeiro ou falso")
|
|
676
|
+
marca = chamada.idempotencia
|
|
677
|
+
mid = f"<{marca[:TAMANHO_DA_CHAVE]}@{DOMINIO_DO_ID}>"
|
|
678
|
+
rascunho = _json(
|
|
679
|
+
{
|
|
680
|
+
"subject": assunto,
|
|
681
|
+
"body": {"contentType": "HTML" if html else "Text", "content": corpo},
|
|
682
|
+
"toRecipients": _destinatarios(destinatarios),
|
|
683
|
+
"ccRecipients": _destinatarios(copia),
|
|
684
|
+
"bccRecipients": _destinatarios(oculta),
|
|
685
|
+
"internetMessageId": mid,
|
|
686
|
+
}
|
|
687
|
+
)
|
|
688
|
+
cliente = _cliente_da_chamada(chamada, porta)
|
|
689
|
+
for _ in range(_cliente.TENTATIVAS):
|
|
690
|
+
try:
|
|
691
|
+
return _enviar_uma_vez(cliente, mid, marca, rascunho)
|
|
692
|
+
except _AindaNaoSaiu:
|
|
693
|
+
if not cliente.prazo.cabe(0):
|
|
694
|
+
break
|
|
695
|
+
raise GraphNaoSaiu("Graph enviar_email: o rascunho não ficou criado; nada foi enviado")
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _enviar_uma_vez(cliente: Cliente, mid: str, marca: str, rascunho: bytes) -> dict[str, Any]:
|
|
699
|
+
if _procurar(cliente, "sentitems", mid, marca) is not None:
|
|
700
|
+
return {"internet_id": mid, "reaproveitado": True}
|
|
701
|
+
achado = _procurar(cliente, "drafts", mid, marca)
|
|
702
|
+
id_do_rascunho = achado["id"] if achado and isinstance(achado.get("id"), str) else None
|
|
703
|
+
if id_do_rascunho is None:
|
|
704
|
+
id_do_rascunho = _criar_rascunho(cliente, rascunho, marca)
|
|
705
|
+
_despachar_o_envio(cliente, mid, marca, id_do_rascunho)
|
|
706
|
+
return {"internet_id": mid, "reaproveitado": False}
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
# ── SharePoint ────────────────────────────────────────────────────────────────────────────
|
|
710
|
+
|
|
711
|
+
_CAMPOS_DO_ARQUIVO = "id,name,size,file,folder,lastModifiedDateTime,webUrl"
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _item(dados: dict[str, Any]) -> dict[str, Any]:
|
|
715
|
+
tamanho = dados.get("size")
|
|
716
|
+
pasta = isinstance(dados.get("folder"), dict)
|
|
717
|
+
return {
|
|
718
|
+
"id": dados.get("id", ""),
|
|
719
|
+
"nome": dados.get("name", ""),
|
|
720
|
+
"tipo": "pasta" if pasta else str((dados.get("file") or {}).get("mimeType") or ""),
|
|
721
|
+
"tamanho": None if pasta or not isinstance(tamanho, int) else tamanho,
|
|
722
|
+
"modificado": dados.get("lastModifiedDateTime", ""),
|
|
723
|
+
"url": dados.get("webUrl", ""),
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
@PLUGIN.operacao
|
|
728
|
+
def listar_arquivos(
|
|
729
|
+
chamada: Any,
|
|
730
|
+
site: str,
|
|
731
|
+
pasta: str = "",
|
|
732
|
+
*,
|
|
733
|
+
limite: int = POR_PAGINA,
|
|
734
|
+
porta: int | None = None,
|
|
735
|
+
) -> list[dict[str, Any]]:
|
|
736
|
+
"""Os itens da ``pasta`` (um caminho como ``Notas/2026``; vazio é a raiz) da biblioteca de
|
|
737
|
+
documentos padrão do ``site`` (``root``, o id ``host,guid,guid`` ou
|
|
738
|
+
``contoso.sharepoint.com:/sites/Financeiro``), até ``limite``. Cada um: ``id``, ``nome``,
|
|
739
|
+
``tipo`` (``pasta`` ou o MIME), ``tamanho`` (``None`` na pasta), ``modificado`` e ``url``."""
|
|
740
|
+
limite = _limite(limite, LIMITE_ARQUIVOS_MAX)
|
|
741
|
+
caminho = _no_drive(_site(site), _caminho(pasta, "pasta", vazio=True), depois="/children")
|
|
742
|
+
cliente = _cliente_da_chamada(chamada, porta)
|
|
743
|
+
|
|
744
|
+
def ler() -> list[dict[str, Any]]:
|
|
745
|
+
itens: list[dict[str, Any]] = []
|
|
746
|
+
parametros = {"$select": _CAMPOS_DO_ARQUIVO, "$top": str(min(POR_PAGINA, limite))}
|
|
747
|
+
for dados in _paginas(cliente, "listar_arquivos", caminho, parametros):
|
|
748
|
+
itens.append(_item(dados))
|
|
749
|
+
if len(itens) >= limite:
|
|
750
|
+
break
|
|
751
|
+
return itens
|
|
752
|
+
|
|
753
|
+
return _lendo("listar_arquivos", ler)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _local_do_download(cliente: Cliente, resposta: _cliente.Resposta, host: str) -> tuple[str, str]:
|
|
757
|
+
"""O caminho e a consulta do ``Location`` da Graph, que só vale se for HTTPS no host do
|
|
758
|
+
tenant que o projeto declarou (o link é dado do servidor: nenhum outro host recebe o
|
|
759
|
+
pedido)."""
|
|
760
|
+
destino = resposta.cabecalho("Location")
|
|
761
|
+
partes = urllib.parse.urlsplit(destino)
|
|
762
|
+
porta_aceita = {None, 443, cliente.porta}
|
|
763
|
+
if (
|
|
764
|
+
partes.scheme != "https"
|
|
765
|
+
or partes.username
|
|
766
|
+
or (partes.hostname or "").lower() != host
|
|
767
|
+
or partes.port not in porta_aceita
|
|
768
|
+
):
|
|
769
|
+
raise ErroDaGraph(
|
|
770
|
+
f"Graph baixar_arquivo: a Graph mandou baixar de {partes.hostname or 'outro lugar'}, "
|
|
771
|
+
f"que não é o {HOST_DO_SHAREPOINT} do projeto ({host})",
|
|
772
|
+
status=resposta.status,
|
|
773
|
+
)
|
|
774
|
+
return partes.path, partes.query
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
@PLUGIN.operacao
|
|
778
|
+
def baixar_arquivo(
|
|
779
|
+
chamada: Any,
|
|
780
|
+
site: str,
|
|
781
|
+
caminho: str,
|
|
782
|
+
*,
|
|
783
|
+
pasta: str = "sharepoint",
|
|
784
|
+
porta: int | None = None,
|
|
785
|
+
) -> dict[str, Any]:
|
|
786
|
+
"""Grava o arquivo ``caminho`` (``Notas/2026/nota.pdf``) da biblioteca padrão do ``site`` em
|
|
787
|
+
``<pasta>/<nome>`` no diretório de trabalho (o nome do SharePoint, limpo de separador e
|
|
788
|
+
controle) e devolve ``id``, ``arquivo`` (o caminho gravado), ``tamanho`` e ``tipo``. O
|
|
789
|
+
arquivo só aparece inteiro: o download que cai no meio não deixa arquivo.
|
|
790
|
+
|
|
791
|
+
A Graph responde o conteúdo com um redirecionamento para o host do tenant no SharePoint, que
|
|
792
|
+
vem de ``[config] sharepoint_host`` (e de ``[hosts] permitidos``); o token da conexão não vai
|
|
793
|
+
a esse host, a URL já é autenticada."""
|
|
794
|
+
host = chamada.host_do_projeto(HOST_DO_SHAREPOINT)
|
|
795
|
+
partes = _caminho(caminho, "caminho", vazio=False)
|
|
796
|
+
endereco_do_site = _site(site)
|
|
797
|
+
base = _no_drive(endereco_do_site, partes)
|
|
798
|
+
destino = _pasta_relativa(pasta)
|
|
799
|
+
cliente = _cliente_da_chamada(chamada, porta)
|
|
800
|
+
raiz = Path.cwd().resolve()
|
|
801
|
+
|
|
802
|
+
def ler() -> dict[str, Any]:
|
|
803
|
+
resposta = cliente.pedir("GET", GRAPH, base, parametros={"$select": _CAMPOS_DO_ARQUIVO})
|
|
804
|
+
_cliente.checar_leitura("baixar_arquivo", resposta)
|
|
805
|
+
dados = _item(resposta.json())
|
|
806
|
+
if dados["tipo"] == "pasta":
|
|
807
|
+
raise ErroDaGraph("Graph baixar_arquivo: o caminho é uma pasta, não um arquivo")
|
|
808
|
+
identificador = dados["id"]
|
|
809
|
+
if not isinstance(identificador, str) or not _ID.fullmatch(identificador):
|
|
810
|
+
raise ErroDaGraph("Graph baixar_arquivo: o item veio sem um id que a Graph reconheça")
|
|
811
|
+
pasta_completa = raiz / destino
|
|
812
|
+
pasta_completa.mkdir(parents=True, exist_ok=True)
|
|
813
|
+
gravado = pasta_completa / _nome_de_arquivo(str(dados["nome"]))
|
|
814
|
+
parcial = pasta_completa / (gravado.name + ".parcial")
|
|
815
|
+
conteudo_em = f"{_drive(endereco_do_site)}/items/{identificador}/content"
|
|
816
|
+
try:
|
|
817
|
+
conteudo = cliente.pedir(
|
|
818
|
+
"GET",
|
|
819
|
+
GRAPH,
|
|
820
|
+
conteudo_em,
|
|
821
|
+
cabecalhos=_QUALQUER_TIPO,
|
|
822
|
+
para_arquivo=parcial,
|
|
823
|
+
tamanho_max=ARQUIVO_MAX,
|
|
824
|
+
)
|
|
825
|
+
if conteudo.status in (301, 302, 303, 307, 308):
|
|
826
|
+
caminho_final, consulta = _local_do_download(cliente, conteudo, host)
|
|
827
|
+
conteudo = cliente.pedir(
|
|
828
|
+
"GET",
|
|
829
|
+
host,
|
|
830
|
+
caminho_final,
|
|
831
|
+
consulta_crua=consulta,
|
|
832
|
+
cabecalhos=_QUALQUER_TIPO,
|
|
833
|
+
para_arquivo=parcial,
|
|
834
|
+
tamanho_max=ARQUIVO_MAX,
|
|
835
|
+
com_token=False,
|
|
836
|
+
)
|
|
837
|
+
if not 200 <= conteudo.status < 300:
|
|
838
|
+
_cliente.checar_leitura("baixar_arquivo", conteudo)
|
|
839
|
+
parcial.replace(gravado)
|
|
840
|
+
finally:
|
|
841
|
+
parcial.unlink(missing_ok=True)
|
|
842
|
+
return {
|
|
843
|
+
"id": identificador,
|
|
844
|
+
"arquivo": str(gravado.relative_to(raiz)),
|
|
845
|
+
"tamanho": conteudo.tamanho,
|
|
846
|
+
"tipo": dados["tipo"],
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return _lendo("baixar_arquivo", ler)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
@PLUGIN.efeito
|
|
853
|
+
def enviar_arquivo(
|
|
854
|
+
chamada: Any,
|
|
855
|
+
arquivo: str | os.PathLike[str],
|
|
856
|
+
site: str,
|
|
857
|
+
pasta: str = "",
|
|
858
|
+
*,
|
|
859
|
+
nome: str | None = None,
|
|
860
|
+
existente: str = "substituir",
|
|
861
|
+
chave_no_nome: bool = False,
|
|
862
|
+
porta: int | None = None,
|
|
863
|
+
) -> dict[str, Any]:
|
|
864
|
+
"""Envia um arquivo do diretório de trabalho (até 100 MB) para a ``pasta`` (um caminho como
|
|
865
|
+
``Comprovantes/2026``; vazio é a raiz) da biblioteca padrão do ``site``, só dentro de
|
|
866
|
+
``item.efetivar()``, com ``nome`` (o do arquivo, se omitido). Devolve ``id``, ``nome``,
|
|
867
|
+
``caminho``, ``tamanho`` e ``criado`` (``False`` quando substituiu um arquivo).
|
|
868
|
+
|
|
869
|
+
O caminho é a chave: repetir (retentativa, retomada, refazer) grava o mesmo arquivo, com o
|
|
870
|
+
mesmo conteúdo, e deixa um arquivo só. ``existente="substituir"`` (o padrão) sobrescreve o
|
|
871
|
+
que estiver no caminho; ``existente="falhar"`` recusa o caminho ocupado
|
|
872
|
+
(``ArquivoJaExiste``). ``chave_no_nome=True`` acrescenta ao nome 12 caracteres da chave da
|
|
873
|
+
chamada (``nota.3fa9c1d2b4e6.pdf``), para o arquivo de um item não sobrescrever o de outro.
|
|
874
|
+
|
|
875
|
+
``GraphNaoSaiu``: nada foi gravado (a Graph recusou, a rede não abriu). ``GraphIncerto``: o
|
|
876
|
+
envio saiu sem confirmação; conferir o arquivo antes de refazer."""
|
|
877
|
+
local = _arquivo_do_workdir(arquivo)
|
|
878
|
+
nome_final = nome if nome is not None else local.name
|
|
879
|
+
if existente not in ("substituir", "falhar"):
|
|
880
|
+
raise ValueError("existente é 'substituir' ou 'falhar'")
|
|
881
|
+
if not isinstance(chave_no_nome, bool):
|
|
882
|
+
raise ValueError("chave_no_nome é verdadeiro ou falso")
|
|
883
|
+
if chave_no_nome:
|
|
884
|
+
raiz_do_nome, extensao = os.path.splitext(str(nome_final))
|
|
885
|
+
nome_final = f"{raiz_do_nome}.{chamada.idempotencia[:TAMANHO_DA_CHAVE_NO_NOME]}{extensao}"
|
|
886
|
+
_segmento(nome_final, "nome")
|
|
887
|
+
partes = [*_caminho(pasta, "pasta", vazio=True), _segmento(nome_final, "nome")]
|
|
888
|
+
dados = local.read_bytes()
|
|
889
|
+
if len(dados) > ENVIO_MAX:
|
|
890
|
+
raise ValueError("arquivo passa de 100 MB: o envio desta versão é de um pedido só")
|
|
891
|
+
caminho = _no_drive(_site(site), partes, depois="/content")
|
|
892
|
+
tipo = mimetypes.guess_type(nome_final)[0] or "application/octet-stream"
|
|
893
|
+
cliente = _cliente_da_chamada(chamada, porta)
|
|
894
|
+
try:
|
|
895
|
+
resposta = cliente.pedir(
|
|
896
|
+
"PUT",
|
|
897
|
+
GRAPH,
|
|
898
|
+
caminho,
|
|
899
|
+
parametros={
|
|
900
|
+
"@microsoft.graph.conflictBehavior": "replace"
|
|
901
|
+
if existente == "substituir"
|
|
902
|
+
else "fail"
|
|
903
|
+
},
|
|
904
|
+
corpo=dados,
|
|
905
|
+
tipo=tipo,
|
|
906
|
+
idempotencia=chamada.idempotencia,
|
|
907
|
+
# Substituir é idempotente (o mesmo caminho, o mesmo conteúdo): repetir depois de uma
|
|
908
|
+
# resposta perdida não muda nada. Falhar não é: a 2ª volta bateria no próprio arquivo.
|
|
909
|
+
repetir_depois_do_envio=existente == "substituir",
|
|
910
|
+
)
|
|
911
|
+
except OSError as exc:
|
|
912
|
+
raise _cliente.falha_de_escrita("enviar_arquivo", exc) from exc
|
|
913
|
+
if resposta.status == 409 and existente == "falhar":
|
|
914
|
+
raise ArquivoJaExiste(
|
|
915
|
+
f"Graph enviar_arquivo: já existe um arquivo nesse caminho ({resposta.status} "
|
|
916
|
+
f"{resposta.codigo})".strip(),
|
|
917
|
+
status=409,
|
|
918
|
+
)
|
|
919
|
+
_cliente.checar_escrita("enviar_arquivo", resposta)
|
|
920
|
+
try:
|
|
921
|
+
criado = resposta.json()
|
|
922
|
+
except ErroDaGraph as exc:
|
|
923
|
+
raise GraphIncerto(
|
|
924
|
+
"Graph enviar_arquivo: a Graph confirmou com um corpo ilegível; o arquivo pode estar "
|
|
925
|
+
"lá",
|
|
926
|
+
status=resposta.status,
|
|
927
|
+
) from exc
|
|
928
|
+
return {
|
|
929
|
+
"id": criado.get("id", ""),
|
|
930
|
+
"nome": criado.get("name", nome_final),
|
|
931
|
+
"caminho": "/".join(urllib.parse.unquote(p) for p in partes),
|
|
932
|
+
"tamanho": criado.get("size", len(dados)),
|
|
933
|
+
"criado": resposta.status == 201,
|
|
934
|
+
}
|